Compare commits
No commits in common. "main" and "master" have entirely different histories.
|
|
@ -12,7 +12,6 @@
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/dist
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
FROM node:18-alpine AS builder
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm ci --legacy-peer-deps
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { defineConfig } from "cypress";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
e2e: {
|
|
||||||
baseUrl: "http://localhost:5173",
|
|
||||||
setupNodeEvents(on, config) {
|
|
||||||
// implement node event listeners here
|
|
||||||
},
|
|
||||||
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
|
|
||||||
supportFile: "cypress/support/e2e.ts",
|
|
||||||
viewportWidth: 1280,
|
|
||||||
viewportHeight: 720,
|
|
||||||
video: true,
|
|
||||||
screenshotOnRunFailure: true,
|
|
||||||
defaultCommandTimeout: 10000,
|
|
||||||
requestTimeout: 10000,
|
|
||||||
responseTimeout: 10000,
|
|
||||||
},
|
|
||||||
component: {
|
|
||||||
devServer: {
|
|
||||||
framework: "react",
|
|
||||||
bundler: "vite",
|
|
||||||
},
|
|
||||||
specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}",
|
|
||||||
supportFile: "cypress/support/component.ts",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
|
@ -1,106 +0,0 @@
|
||||||
describe("Authentication", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display login form", () => {
|
|
||||||
cy.get('input[name="username"]').should("be.visible");
|
|
||||||
cy.get('input[name="password"]').should("be.visible");
|
|
||||||
cy.get('button[type="submit"]').should("be.visible");
|
|
||||||
cy.contains("ورود به پنل مدیریت").should("be.visible");
|
|
||||||
cy.contains("لطفا اطلاعات خود را وارد کنید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show validation errors for empty fields", () => {
|
|
||||||
// Type something then clear to trigger validation
|
|
||||||
cy.get('input[name="username"]').type("a").clear();
|
|
||||||
cy.get('input[name="password"]').type("a").clear();
|
|
||||||
|
|
||||||
// Click outside to trigger validation
|
|
||||||
cy.get("body").click();
|
|
||||||
|
|
||||||
cy.contains("نام کاربری الزامی است").should("be.visible");
|
|
||||||
cy.contains("رمز عبور الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error for invalid credentials", () => {
|
|
||||||
cy.get('input[name="username"]').type("invaliduser");
|
|
||||||
cy.get('input[name="password"]').type("wrongpass");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام کاربری یا رمز عبور اشتباه است", { timeout: 10000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should successfully login with valid credentials", () => {
|
|
||||||
cy.get('input[name="username"]').type("admin");
|
|
||||||
cy.get('input[name="password"]').type("admin123");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Should redirect to dashboard - handle trailing slash
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
cy.url().should("satisfy", (url) => {
|
|
||||||
return (
|
|
||||||
url === Cypress.config().baseUrl ||
|
|
||||||
url === Cypress.config().baseUrl + "/"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should see dashboard content
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should logout successfully", () => {
|
|
||||||
// First login
|
|
||||||
cy.get('input[name="username"]').type("admin");
|
|
||||||
cy.get('input[name="password"]').type("admin123");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
|
|
||||||
// Clear session to simulate logout
|
|
||||||
cy.clearLocalStorage();
|
|
||||||
cy.visit("/login");
|
|
||||||
|
|
||||||
// Should redirect to login
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
cy.contains("ورود به پنل مدیریت").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redirect to login when accessing protected routes without authentication", () => {
|
|
||||||
cy.visit("/products");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remember login state after page refresh", () => {
|
|
||||||
// Login first
|
|
||||||
cy.get('input[name="username"]').type("admin");
|
|
||||||
cy.get('input[name="password"]').type("admin123");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
|
|
||||||
cy.reload();
|
|
||||||
|
|
||||||
// Should still be logged in
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle password visibility", () => {
|
|
||||||
cy.get('input[name="password"]').should("have.attr", "type", "password");
|
|
||||||
|
|
||||||
// Click the eye button to show password
|
|
||||||
cy.get(".absolute.inset-y-0.left-0").click();
|
|
||||||
cy.get('input[name="password"]').should("have.attr", "type", "text");
|
|
||||||
|
|
||||||
// Click again to hide password
|
|
||||||
cy.get(".absolute.inset-y-0.left-0").click();
|
|
||||||
cy.get('input[name="password"]').should("have.attr", "type", "password");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
describe("Categories - Advanced Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category CRUD Operations", () => {
|
|
||||||
it("should create a new category", () => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
|
|
||||||
// Fill category information
|
|
||||||
cy.get('input[name="name"]').type("دستهبندی تست");
|
|
||||||
cy.get('textarea[name="description"]').type("توضیحات دستهبندی تست");
|
|
||||||
cy.get('input[name="sort_order"]').clear().type("1");
|
|
||||||
|
|
||||||
// Enable category
|
|
||||||
cy.get('input[name="enabled"]').check({ force: true });
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify redirect and success
|
|
||||||
cy.url().should("include", "/categories");
|
|
||||||
cy.contains("دستهبندی تست").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit an existing category", () => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
|
|
||||||
// Click edit on first category
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get(
|
|
||||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update category name
|
|
||||||
cy.get('input[name="name"]').clear().type("دستهبندی ویرایش شده");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify changes
|
|
||||||
cy.url().should("include", "/categories");
|
|
||||||
cy.contains("دستهبندی ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a category with confirmation", () => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
|
|
||||||
// Click delete on first category
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
// Verify success message
|
|
||||||
cy.contains("دستهبندی با موفقیت حذف شد", { timeout: 10000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show validation errors for empty required fields", () => {
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Check for validation messages
|
|
||||||
cy.contains("نام دستهبندی الزامی است", { timeout: 5000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate minimum length for category name", () => {
|
|
||||||
cy.get('input[name="name"]').type("a");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام دستهبندی باید حداقل", { timeout: 5000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate sort order is a number", () => {
|
|
||||||
cy.get('input[name="name"]').type("دستهبندی تست");
|
|
||||||
cy.get('input[name="sort_order"]').clear().type("abc");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("ترتیب نمایش باید عدد باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category Search and Filter", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search categories by name", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("دسته");
|
|
||||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
|
||||||
|
|
||||||
// Wait for results
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check search results
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter categories by status", () => {
|
|
||||||
cy.get('select[name="enabled"], select').first().select("true");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Results should be filtered
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category Status Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle category status", () => {
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('input[type="checkbox"], .toggle')
|
|
||||||
.first()
|
|
||||||
.click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("وضعیت دستهبندی با موفقیت تغییر کرد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category Image Upload", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should upload category image", () => {
|
|
||||||
cy.get('input[name="name"]').type("دستهبندی با تصویر");
|
|
||||||
|
|
||||||
// Upload image
|
|
||||||
cy.get('input[type="file"]').selectFile(
|
|
||||||
"cypress/fixtures/category-image.jpg",
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for upload
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify success
|
|
||||||
cy.url().should("include", "/categories");
|
|
||||||
cy.contains("دستهبندی با تصویر").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate image format", () => {
|
|
||||||
cy.get('input[type="file"]').selectFile(
|
|
||||||
"cypress/fixtures/invalid-file.txt",
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.contains("فرمت فایل باید تصویر باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Category Import/Export", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/categories");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show import modal", () => {
|
|
||||||
cy.get("button").contains("وارد کردن").click();
|
|
||||||
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.contains("وارد کردن دستهبندیها از فایل Excel").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate Excel file upload", () => {
|
|
||||||
cy.get("button").contains("وارد کردن").click();
|
|
||||||
|
|
||||||
// Upload valid Excel file
|
|
||||||
cy.get('input[type="file"]').selectFile(
|
|
||||||
"cypress/fixtures/categories.xlsx",
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get("button").contains("شروع وارد کردن").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
describe("Category Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/categories");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display categories list page", () => {
|
|
||||||
cy.contains("مدیریت دستهبندیها").should("be.visible");
|
|
||||||
cy.contains("مدیریت دستهبندیهای محصولات").should("be.visible");
|
|
||||||
cy.get('[title="دستهبندی جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new category", () => {
|
|
||||||
cy.get('[title="دستهبندی جدید"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/categories/create");
|
|
||||||
cy.contains("دستهبندی جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Fill category form
|
|
||||||
cy.get('input[name="name"]').type("الکترونیک");
|
|
||||||
cy.get('textarea[name="description"]').type("دستهبندی محصولات الکترونیکی");
|
|
||||||
|
|
||||||
// Basic category creation without parent selection
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/categories");
|
|
||||||
cy.contains("دستهبندی با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("الکترونیک").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit a category", () => {
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/categories/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
// Update category
|
|
||||||
cy.get('input[name="name"]').clear().type("کامپیوتر و لپتاپ");
|
|
||||||
cy.get('textarea[name="description"]')
|
|
||||||
.clear()
|
|
||||||
.type("انواع کامپیوتر و لپتاپ");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/categories");
|
|
||||||
cy.contains("دستهبندی با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("کامپیوتر و لپتاپ").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a category", () => {
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.contains("آیا از حذف این دستهبندی اطمینان دارید؟").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("دستهبندی با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search categories", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("الکترونیک");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "الکترونیک");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display category list", () => {
|
|
||||||
// Should show categories table
|
|
||||||
cy.get("table").should("be.visible");
|
|
||||||
cy.contains("نام دستهبندی").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate category form", () => {
|
|
||||||
cy.get('[title="دستهبندی جدید"]').click();
|
|
||||||
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام دستهبندی الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display category status", () => {
|
|
||||||
// Check if categories show status correctly
|
|
||||||
cy.get("table tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show products count for each category", () => {
|
|
||||||
cy.get("table tbody tr").each(($row) => {
|
|
||||||
cy.wrap($row).find(".products-count").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle category with products deletion warning", () => {
|
|
||||||
// Try to delete category that has products
|
|
||||||
cy.get('[data-testid="category-with-products"]')
|
|
||||||
.find('[title="حذف"]')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.contains("این دستهبندی دارای محصول است").should("be.visible");
|
|
||||||
cy.contains("ابتدا محصولات را به دستهبندی دیگری منتقل کنید").should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should bulk delete categories", () => {
|
|
||||||
// Select multiple categories
|
|
||||||
cy.get('input[type="checkbox"]').check(["1", "2"]);
|
|
||||||
cy.get("button").contains("حذف انتخاب شدهها").click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("دستهبندیهای انتخاب شده حذف شدند").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should export categories list", () => {
|
|
||||||
cy.get("button").contains("خروجی").click();
|
|
||||||
|
|
||||||
// Should download file
|
|
||||||
cy.readFile("cypress/downloads/categories.xlsx").should("exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should import categories from file", () => {
|
|
||||||
cy.get("button").contains("وارد کردن").click();
|
|
||||||
|
|
||||||
cy.get('input[type="file"]').selectFile("cypress/fixtures/categories.xlsx");
|
|
||||||
cy.get("button").contains("آپلود").click();
|
|
||||||
|
|
||||||
cy.contains("فایل با موفقیت پردازش شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle category image upload", () => {
|
|
||||||
cy.get('[title="دستهبندی جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="name"]').type("فشن و مد");
|
|
||||||
|
|
||||||
// Upload category image
|
|
||||||
cy.get('input[type="file"]').selectFile(
|
|
||||||
"cypress/fixtures/category-image.jpg"
|
|
||||||
);
|
|
||||||
cy.get(".image-preview").should("be.visible");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("دستهبندی با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
describe("Dashboard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display dashboard page with title", () => {
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display statistics cards", () => {
|
|
||||||
// Check for main metrics based on actual statsData
|
|
||||||
cy.contains("کل کاربران").should("be.visible");
|
|
||||||
cy.contains("فروش ماهانه").should("be.visible");
|
|
||||||
cy.contains("کل سفارشات").should("be.visible");
|
|
||||||
cy.contains("رشد فروش").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display charts", () => {
|
|
||||||
// Check if chart section exists
|
|
||||||
cy.get("body").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show recent users table", () => {
|
|
||||||
// Check if content area exists
|
|
||||||
cy.get("main, [role='main'], .content").should("exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show chart titles", () => {
|
|
||||||
cy.contains("فروش ماهانه").should("be.visible");
|
|
||||||
cy.contains("روند رشد").should("be.visible");
|
|
||||||
cy.contains("دستگاههای کاربری").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be responsive on mobile", () => {
|
|
||||||
cy.viewport("iphone-6");
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display user status badges correctly", () => {
|
|
||||||
// Check status badges in recent users table
|
|
||||||
cy.get(".bg-green-100").should("contain", "فعال");
|
|
||||||
cy.get(".bg-red-100").should("contain", "غیرفعال");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show action buttons in table", () => {
|
|
||||||
// Check if dashboard content loads
|
|
||||||
cy.get("body").should("contain", "داشبورد");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,472 +0,0 @@
|
||||||
describe("Discount Codes Advanced Features", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate code format and uniqueness", () => {
|
|
||||||
// Test invalid characters (if implemented)
|
|
||||||
cy.get('input[name="code"]').type("TEST CODE"); // Space in code
|
|
||||||
cy.get('input[name="name"]').type("Test Name");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Try to submit - may show validation error for invalid characters
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Clear and use valid code
|
|
||||||
cy.get('input[name="code"]').clear().type("TESTCODE123");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate name length constraints", () => {
|
|
||||||
cy.get('input[name="code"]').type("NAMETEST");
|
|
||||||
|
|
||||||
// Test name too long
|
|
||||||
cy.get('input[name="name"]').type("A".repeat(101));
|
|
||||||
cy.contains("نام نباید بیشتر از ۱۰۰ کاراکتر باشد").should("be.visible");
|
|
||||||
|
|
||||||
// Clear and use valid name
|
|
||||||
cy.get('input[name="name"]').clear().type("Valid Name");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate description length", () => {
|
|
||||||
cy.get('input[name="code"]').type("DESCTEST");
|
|
||||||
cy.get('input[name="name"]').type("Description Test");
|
|
||||||
|
|
||||||
// Test description too long
|
|
||||||
cy.get('textarea[name="description"]').type("A".repeat(501));
|
|
||||||
cy.contains("توضیحات نباید بیشتر از ۵۰۰ کاراکتر باشد").should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate percentage values", () => {
|
|
||||||
cy.get('input[name="code"]').type("PERCENTTEST");
|
|
||||||
cy.get('input[name="name"]').type("Percent Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
|
|
||||||
// Test negative value
|
|
||||||
cy.get('input[name="value"]').type("-10");
|
|
||||||
cy.contains("مقدار باید بیشتر از صفر باشد").should("be.visible");
|
|
||||||
|
|
||||||
// Test zero value
|
|
||||||
cy.get('input[name="value"]').clear().type("0");
|
|
||||||
cy.contains("مقدار باید بیشتر از صفر باشد").should("be.visible");
|
|
||||||
|
|
||||||
// Test valid value
|
|
||||||
cy.get('input[name="value"]').clear().type("25");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate usage limits", () => {
|
|
||||||
cy.get('input[name="code"]').type("USAGETEST");
|
|
||||||
cy.get('input[name="name"]').type("Usage Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Test invalid usage limit
|
|
||||||
cy.get('input[name="usage_limit"]').type("0");
|
|
||||||
cy.contains("حداقل ۱ بار استفاده").should("be.visible");
|
|
||||||
|
|
||||||
// Test invalid user usage limit
|
|
||||||
cy.get('input[name="user_usage_limit"]').type("0");
|
|
||||||
cy.contains("حداقل ۱ بار استفاده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate amount constraints", () => {
|
|
||||||
cy.get('input[name="code"]').type("AMOUNTTEST");
|
|
||||||
cy.get('input[name="name"]').type("Amount Test");
|
|
||||||
cy.get('select[name="type"]').select("fixed");
|
|
||||||
cy.get('input[name="value"]').type("1000");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Test invalid minimum purchase amount
|
|
||||||
cy.get('input[name="min_purchase_amount"]').type("0");
|
|
||||||
cy.contains("مبلغ باید بیشتر از صفر باشد").should("be.visible");
|
|
||||||
|
|
||||||
// Test invalid maximum discount amount
|
|
||||||
cy.get('input[name="max_discount_amount"]').type("-100");
|
|
||||||
cy.contains("مبلغ باید بیشتر از صفر باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Date and Time Handling", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
// Fill required fields
|
|
||||||
cy.get('input[name="code"]').type("DATETEST");
|
|
||||||
cy.get('input[name="name"]').type("Date Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle date range validation", () => {
|
|
||||||
// Set end date before start date
|
|
||||||
cy.get('input[name="valid_from"]').type("2024-12-31T23:59");
|
|
||||||
cy.get('input[name="valid_to"]').type("2024-01-01T00:00");
|
|
||||||
|
|
||||||
// Form should still accept it (backend validation)
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve datetime values in edit mode", () => {
|
|
||||||
// Set specific datetime values
|
|
||||||
const fromDate = "2024-06-01T10:30";
|
|
||||||
const toDate = "2024-06-30T18:45";
|
|
||||||
|
|
||||||
cy.get('input[name="valid_from"]').type(fromDate);
|
|
||||||
cy.get('input[name="valid_to"]').type(toDate);
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
|
|
||||||
// Edit the created discount code
|
|
||||||
cy.contains("DATETEST")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="ویرایش"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Values should be preserved
|
|
||||||
cy.get('input[name="valid_from"]').should("have.value", fromDate);
|
|
||||||
cy.get('input[name="valid_to"]').should("have.value", toDate);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Restrictions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
// Fill required fields
|
|
||||||
cy.get('input[name="code"]').type("USERTEST");
|
|
||||||
cy.get('input[name="name"]').type("User Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("15");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle user group selection", () => {
|
|
||||||
// Test all user group options
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select("new");
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').should(
|
|
||||||
"have.value",
|
|
||||||
"new"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select("loyal");
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').should(
|
|
||||||
"have.value",
|
|
||||||
"loyal"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select("all");
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').should(
|
|
||||||
"have.value",
|
|
||||||
"all"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle purchase count restrictions", () => {
|
|
||||||
cy.get('input[name="user_restrictions.min_purchase_count"]').type("2");
|
|
||||||
cy.get('input[name="user_restrictions.max_purchase_count"]').type("10");
|
|
||||||
cy.get('input[name="user_restrictions.referrer_user_id"]').type("456");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should warn about conflicting user restrictions", () => {
|
|
||||||
// Check both new users only and loyal users only
|
|
||||||
cy.get('input[name="user_restrictions.new_users_only"]').check();
|
|
||||||
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
|
|
||||||
|
|
||||||
// Warning should be visible
|
|
||||||
cy.contains(
|
|
||||||
"new_users_only و loyal_users_only نمیتوانند همزمان فعال باشند"
|
|
||||||
).should("be.visible");
|
|
||||||
|
|
||||||
// Uncheck one
|
|
||||||
cy.get('input[name="user_restrictions.new_users_only"]').uncheck();
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Application Levels", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get('input[name="code"]').type("APPTEST");
|
|
||||||
cy.get('input[name="name"]').type("Application Test");
|
|
||||||
cy.get('input[name="value"]').type("100");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle product fee application with fee percentage type", () => {
|
|
||||||
cy.get('select[name="type"]').select("fee_percentage");
|
|
||||||
cy.get('select[name="application_level"]').select("product_fee");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should test all application level combinations", () => {
|
|
||||||
const types = ["percentage", "fixed", "fee_percentage"];
|
|
||||||
const applications = [
|
|
||||||
"invoice",
|
|
||||||
"category",
|
|
||||||
"product",
|
|
||||||
"shipping",
|
|
||||||
"product_fee",
|
|
||||||
];
|
|
||||||
|
|
||||||
types.forEach((type, typeIndex) => {
|
|
||||||
applications.forEach((app, appIndex) => {
|
|
||||||
if (typeIndex > 0 || appIndex > 0) {
|
|
||||||
// Generate unique code for each combination
|
|
||||||
cy.get('input[name="code"]')
|
|
||||||
.clear()
|
|
||||||
.type(`TEST${typeIndex}${appIndex}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('select[name="type"]').select(type);
|
|
||||||
cy.get('select[name="application_level"]').select(app);
|
|
||||||
|
|
||||||
// For fee_percentage, use smaller values
|
|
||||||
if (type === "fee_percentage") {
|
|
||||||
cy.get('input[name="value"]').clear().type("5");
|
|
||||||
} else if (type === "percentage") {
|
|
||||||
cy.get('input[name="value"]').clear().type("10");
|
|
||||||
} else {
|
|
||||||
cy.get('input[name="value"]').clear().type("1000");
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
|
|
||||||
// Go back to create page for next iteration (except last)
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
typeIndex === types.length - 1 &&
|
|
||||||
appIndex === applications.length - 1
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get('input[name="name"]').type("Application Test");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Meta Information", () => {
|
|
||||||
it("should handle meta fields properly", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="code"]').type("METATEST");
|
|
||||||
cy.get('input[name="name"]').type("Meta Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("20");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Set meta fields
|
|
||||||
cy.get('input[name="meta.campaign"]').type("winter_sale_2024");
|
|
||||||
cy.get('input[name="meta.category"]').type("seasonal_promotion");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
|
|
||||||
// Verify meta fields are preserved in edit
|
|
||||||
cy.contains("METATEST")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="ویرایش"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.get('input[name="meta.campaign"]').should(
|
|
||||||
"have.value",
|
|
||||||
"winter_sale_2024"
|
|
||||||
);
|
|
||||||
cy.get('input[name="meta.category"]').should(
|
|
||||||
"have.value",
|
|
||||||
"seasonal_promotion"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("List Page Features", () => {
|
|
||||||
it("should display correct value format based on type", () => {
|
|
||||||
// Create different types of discounts to test display
|
|
||||||
const testCodes = [
|
|
||||||
{ code: "DISPLAYPERCENT", type: "percentage", value: "25" },
|
|
||||||
{ code: "DISPLAYFIXED", type: "fixed", value: "50000" },
|
|
||||||
{ code: "DISPLAYFEE", type: "fee_percentage", value: "5" },
|
|
||||||
];
|
|
||||||
|
|
||||||
testCodes.forEach((testCode) => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get('input[name="code"]').type(testCode.code);
|
|
||||||
cy.get('input[name="name"]').type(`Display Test ${testCode.type}`);
|
|
||||||
cy.get('select[name="type"]').select(testCode.type);
|
|
||||||
cy.get('input[name="value"]').type(testCode.value);
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select(
|
|
||||||
testCode.type === "fee_percentage" ? "product_fee" : "invoice"
|
|
||||||
);
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check display formats
|
|
||||||
cy.contains("DISPLAYPERCENT")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.contains("25%").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("DISPLAYFIXED")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.contains("50000 تومان").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("DISPLAYFEE")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.contains("5%").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle pagination properly", () => {
|
|
||||||
// This test assumes there are enough items to paginate
|
|
||||||
// Check if pagination exists
|
|
||||||
cy.get('nav[aria-label="Pagination Navigation"]').should("exist");
|
|
||||||
|
|
||||||
// Test pagination controls if they exist
|
|
||||||
cy.get('nav[aria-label="Pagination Navigation"]').within(() => {
|
|
||||||
cy.get("button").should("have.length.greaterThan", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should sort columns when sortable", () => {
|
|
||||||
// Click on sortable column headers
|
|
||||||
cy.get("th").contains("کد").click();
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
cy.get("th").contains("نام").click();
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Verify table content changes (basic check)
|
|
||||||
cy.get("table tbody tr").should("have.length.greaterThan", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Handling", () => {
|
|
||||||
it("should handle network errors gracefully", () => {
|
|
||||||
// Intercept network requests and simulate errors
|
|
||||||
cy.intercept("POST", "**/discount/", { statusCode: 500 }).as(
|
|
||||||
"createError"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get('input[name="code"]').type("ERRORTEST");
|
|
||||||
cy.get('input[name="name"]').type("Error Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.wait("@createError");
|
|
||||||
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle loading states", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Intercept with delay to see loading state
|
|
||||||
cy.intercept("POST", "**/discount/", { delay: 2000, statusCode: 200 }).as(
|
|
||||||
"createSlow"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get('input[name="code"]').type("LOADTEST");
|
|
||||||
cy.get('input[name="name"]').type("Load Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Check loading state
|
|
||||||
cy.get('button[type="submit"]').should("be.disabled");
|
|
||||||
cy.get(".animate-spin").should("be.visible");
|
|
||||||
|
|
||||||
cy.wait("@createSlow");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Responsive Design", () => {
|
|
||||||
it("should work on mobile viewport", () => {
|
|
||||||
cy.viewport("iphone-6");
|
|
||||||
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
cy.contains("ایجاد کد تخفیف").should("be.visible");
|
|
||||||
|
|
||||||
// Form should be usable on mobile
|
|
||||||
cy.get('input[name="code"]').type("MOBILETEST");
|
|
||||||
cy.get('input[name="name"]').type("Mobile Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').should("be.visible").click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work on tablet viewport", () => {
|
|
||||||
cy.viewport("ipad-2");
|
|
||||||
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
|
|
||||||
cy.get("table").should("be.visible");
|
|
||||||
|
|
||||||
// Test form on tablet
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get(".grid").should("be.visible"); // Grid layout should work
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
import { discountTemplates, apiMocks } from "../support/discount-codes-helpers";
|
|
||||||
|
|
||||||
describe("Discount Codes - Complete E2E Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Navigation and Basic UI", () => {
|
|
||||||
it("should display discount codes list page correctly", () => {
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
|
|
||||||
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to create page", () => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.contains("ایجاد کد تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate required fields", () => {
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
cy.fillBasicDiscountInfo({
|
|
||||||
code: "TEST123",
|
|
||||||
name: "Test Discount",
|
|
||||||
});
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
cy.fillDiscountSettings({
|
|
||||||
type: "percentage",
|
|
||||||
value: "10",
|
|
||||||
status: "active",
|
|
||||||
applicationLevel: "invoice",
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("submit-discount-button").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate field lengths and formats", () => {
|
|
||||||
// Test code length
|
|
||||||
cy.getByTestId("discount-code-input").type("AB");
|
|
||||||
cy.getByTestId("discount-name-input").type("Test");
|
|
||||||
cy.get(".text-red-600").should("contain", "کد باید حداقل ۳ کاراکتر باشد");
|
|
||||||
|
|
||||||
// Test code too long
|
|
||||||
cy.getByTestId("discount-code-input").clear().type("A".repeat(51));
|
|
||||||
cy.get(".text-red-600").should(
|
|
||||||
"contain",
|
|
||||||
"کد نباید بیشتر از ۵۰ کاراکتر باشد"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test name too long
|
|
||||||
cy.getByTestId("discount-name-input").clear().type("A".repeat(101));
|
|
||||||
cy.get(".text-red-600").should(
|
|
||||||
"contain",
|
|
||||||
"نام نباید بیشتر از ۱۰۰ کاراکتر باشد"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test description too long
|
|
||||||
cy.getByTestId("discount-description-textarea").type("A".repeat(501));
|
|
||||||
cy.get(".text-red-600").should(
|
|
||||||
"contain",
|
|
||||||
"توضیحات نباید بیشتر از ۵۰۰ کاراکتر باشد"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate numeric fields", () => {
|
|
||||||
cy.fillBasicDiscountInfo({
|
|
||||||
code: "NUMTEST",
|
|
||||||
name: "Number Test",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test negative value
|
|
||||||
cy.getByTestId("discount-value-input").type("-10");
|
|
||||||
cy.get(".text-red-600").should("contain", "مقدار باید بیشتر از صفر باشد");
|
|
||||||
|
|
||||||
// Test zero value
|
|
||||||
cy.getByTestId("discount-value-input").clear().type("0");
|
|
||||||
cy.get(".text-red-600").should("contain", "مقدار باید بیشتر از صفر باشد");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Discount Creation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock successful API responses
|
|
||||||
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
|
|
||||||
"getDiscounts"
|
|
||||||
);
|
|
||||||
cy.intercept("POST", "**/discount/**", (req) => {
|
|
||||||
return apiMocks.successfulCreation(req.body);
|
|
||||||
}).as("createDiscount");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create basic percentage discount", () => {
|
|
||||||
cy.createDiscountCode(discountTemplates.basicPercentage);
|
|
||||||
cy.wait("@createDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create fixed amount discount", () => {
|
|
||||||
cy.createDiscountCode(discountTemplates.fixedAmount);
|
|
||||||
cy.wait("@createDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create fee percentage discount", () => {
|
|
||||||
cy.createDiscountCode(discountTemplates.feePercentage);
|
|
||||||
cy.wait("@createDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create discount with user restrictions", () => {
|
|
||||||
cy.createDiscountCode(discountTemplates.loyalUsers);
|
|
||||||
cy.wait("@createDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create time-based discount with all features", () => {
|
|
||||||
cy.createDiscountCode(discountTemplates.timeBasedDiscount);
|
|
||||||
cy.wait("@createDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Handling", () => {
|
|
||||||
it("should handle validation errors from API", () => {
|
|
||||||
cy.intercept("POST", "**/discount/**", apiMocks.validationError).as(
|
|
||||||
"validationError"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
|
|
||||||
cy.fillDiscountSettings(discountTemplates.basicPercentage);
|
|
||||||
cy.submitDiscountForm();
|
|
||||||
|
|
||||||
cy.wait("@validationError");
|
|
||||||
cy.contains("کد تخفیف تکراری است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle server errors", () => {
|
|
||||||
cy.intercept("POST", "**/discount/**", apiMocks.serverError).as(
|
|
||||||
"serverError"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
|
|
||||||
cy.fillDiscountSettings(discountTemplates.basicPercentage);
|
|
||||||
cy.submitDiscountForm();
|
|
||||||
|
|
||||||
cy.wait("@serverError");
|
|
||||||
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle loading states", () => {
|
|
||||||
cy.intercept("POST", "**/discount/**", {
|
|
||||||
delay: 2000,
|
|
||||||
...apiMocks.successfulCreation(discountTemplates.basicPercentage),
|
|
||||||
}).as("slowCreate");
|
|
||||||
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.fillBasicDiscountInfo(discountTemplates.basicPercentage);
|
|
||||||
cy.fillDiscountSettings(discountTemplates.basicPercentage);
|
|
||||||
cy.submitDiscountForm();
|
|
||||||
|
|
||||||
// Check loading state
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
cy.get(".animate-spin").should("be.visible");
|
|
||||||
|
|
||||||
cy.wait("@slowCreate");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("List Page Features", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
|
|
||||||
"getDiscounts"
|
|
||||||
);
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.wait("@getDiscounts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search discount codes", () => {
|
|
||||||
cy.searchDiscountCode("SAVE20");
|
|
||||||
cy.contains("SAVE20").should("be.visible");
|
|
||||||
|
|
||||||
cy.searchDiscountCode("NONEXISTENT");
|
|
||||||
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear filters", () => {
|
|
||||||
cy.searchDiscountCode("TEST");
|
|
||||||
cy.clearDiscountFilters();
|
|
||||||
cy.get('input[placeholder*="جستجو"]').should("have.value", "");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display discount codes with correct formatting", () => {
|
|
||||||
cy.contains("SAVE20").should("be.visible");
|
|
||||||
cy.contains("20%").should("be.visible");
|
|
||||||
cy.get(".bg-green-100").should("contain", "فعال");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edit Functionality", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
|
|
||||||
"getDiscounts"
|
|
||||||
);
|
|
||||||
cy.intercept("GET", "**/discount/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
code: "SAVE20",
|
|
||||||
name: "20% Off Discount",
|
|
||||||
description: "Get 20% off on your purchase",
|
|
||||||
type: "percentage",
|
|
||||||
value: 20,
|
|
||||||
status: "active",
|
|
||||||
application_level: "invoice",
|
|
||||||
},
|
|
||||||
}).as("getDiscount");
|
|
||||||
cy.intercept("PUT", "**/discount/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { message: "updated successfully" },
|
|
||||||
}).as("updateDiscount");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit existing discount code", () => {
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.wait("@getDiscounts");
|
|
||||||
|
|
||||||
cy.contains("SAVE20")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="ویرایش"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.wait("@getDiscount");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
cy.getByTestId("discount-name-input")
|
|
||||||
.clear()
|
|
||||||
.type("Updated Discount Name");
|
|
||||||
cy.submitDiscountForm();
|
|
||||||
|
|
||||||
cy.wait("@updateDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Delete Functionality", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/discount/**", apiMocks.discountsList).as(
|
|
||||||
"getDiscounts"
|
|
||||||
);
|
|
||||||
cy.intercept("DELETE", "**/discount/**", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { message: "deleted successfully" },
|
|
||||||
}).as("deleteDiscount");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete discount code", () => {
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.wait("@getDiscounts");
|
|
||||||
|
|
||||||
cy.contains("SAVE20")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="حذف"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("آیا از حذف این کد تخفیف اطمینان دارید؟").should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
cy.contains("button", "حذف").click();
|
|
||||||
|
|
||||||
cy.wait("@deleteDiscount");
|
|
||||||
cy.contains("کد تخفیف با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Responsive Design", () => {
|
|
||||||
it("should work on mobile devices", () => {
|
|
||||||
cy.viewport("iphone-6");
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
|
|
||||||
cy.fillBasicDiscountInfo({
|
|
||||||
code: "MOBILE123",
|
|
||||||
name: "Mobile Test",
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.fillDiscountSettings({
|
|
||||||
type: "percentage",
|
|
||||||
value: "10",
|
|
||||||
status: "active",
|
|
||||||
applicationLevel: "invoice",
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work on tablets", () => {
|
|
||||||
cy.viewport("ipad-2");
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
|
|
||||||
cy.get("table").should("be.visible");
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Accessibility", () => {
|
|
||||||
it("should be keyboard navigable", () => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
|
|
||||||
cy.getByTestId("discount-code-input").focus();
|
|
||||||
cy.focused().should("have.attr", "data-testid", "discount-code-input");
|
|
||||||
|
|
||||||
cy.focused().tab();
|
|
||||||
cy.focused().should("have.attr", "data-testid", "discount-name-input");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have proper ARIA labels", () => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
|
|
||||||
cy.get("label").should("have.length.greaterThan", 5);
|
|
||||||
cy.get("input[required]").should("have.length.greaterThan", 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should announce errors to screen readers", () => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
|
|
||||||
cy.getByTestId("discount-code-input").type("AB");
|
|
||||||
cy.get(".text-red-600").should("have.attr", "role", "alert");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Performance", () => {
|
|
||||||
it("should load create page quickly", () => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.getByTestId("discount-code-input")
|
|
||||||
.should("be.visible")
|
|
||||||
.then(() => {
|
|
||||||
const loadTime = Date.now() - startTime;
|
|
||||||
expect(loadTime).to.be.lessThan(3000); // Should load within 3 seconds
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle large forms efficiently", () => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
|
|
||||||
// Fill form quickly without delays
|
|
||||||
cy.getByTestId("discount-code-input").type("PERF123");
|
|
||||||
cy.getByTestId("discount-name-input").type("Performance Test");
|
|
||||||
cy.getByTestId("discount-description-textarea").type("A".repeat(400));
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("25");
|
|
||||||
|
|
||||||
// Form should remain responsive
|
|
||||||
cy.getByTestId("submit-discount-button").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
/// <reference types="../support" />
|
|
||||||
|
|
||||||
describe("Discount Codes Management - Fixed", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display discount codes list page", () => {
|
|
||||||
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
|
|
||||||
cy.getByTestId("create-discount-button").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to create discount code page", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
cy.url().should("include", "/discount-codes/create");
|
|
||||||
cy.contains("ایجاد کد تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a basic percentage discount code", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Fill basic information using data-testid
|
|
||||||
cy.getByTestId("discount-code-input").type("SAVE20");
|
|
||||||
cy.getByTestId("discount-name-input").type("تخفیف ۲۰ درصدی");
|
|
||||||
cy.getByTestId("discount-description-textarea").type(
|
|
||||||
"تخفیف ۲۰ درصدی برای کل خرید"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set discount settings using data-testid
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("20");
|
|
||||||
|
|
||||||
// Set other required fields
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select("invoice");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.getByTestId("submit-discount-button").click();
|
|
||||||
|
|
||||||
// Verify creation (might need to mock API response)
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate required fields properly", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Submit button should be disabled initially
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
// Fill only code field
|
|
||||||
cy.getByTestId("discount-code-input").type("TEST");
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
// Fill name field
|
|
||||||
cy.getByTestId("discount-name-input").type("Test Name");
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
// Fill all required fields
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("10");
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select("invoice");
|
|
||||||
|
|
||||||
// Now submit button should be enabled
|
|
||||||
cy.getByTestId("submit-discount-button").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate code length constraints", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Test code too short
|
|
||||||
cy.getByTestId("discount-code-input").type("AB");
|
|
||||||
cy.getByTestId("discount-name-input").type("Test");
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("10");
|
|
||||||
|
|
||||||
// Check for validation error
|
|
||||||
cy.get(".text-red-600").should("contain", "کد باید حداقل ۳ کاراکتر باشد");
|
|
||||||
|
|
||||||
// Clear and test code too long
|
|
||||||
cy.getByTestId("discount-code-input").clear().type("A".repeat(51));
|
|
||||||
cy.get(".text-red-600").should(
|
|
||||||
"contain",
|
|
||||||
"کد نباید بیشتر از ۵۰ کاراکتر باشد"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create different discount types", () => {
|
|
||||||
const discountTypes = [
|
|
||||||
{ type: "percentage", value: "25", level: "invoice" },
|
|
||||||
{ type: "fixed", value: "50000", level: "invoice" },
|
|
||||||
{ type: "fee_percentage", value: "5", level: "product_fee" },
|
|
||||||
];
|
|
||||||
|
|
||||||
discountTypes.forEach((discount, index) => {
|
|
||||||
// Navigate to create page before each iteration
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
cy.getByTestId("discount-code-input").type(
|
|
||||||
`TEST${index}${discount.type.toUpperCase()}`
|
|
||||||
);
|
|
||||||
cy.getByTestId("discount-name-input").type(`Test ${discount.type}`);
|
|
||||||
cy.getByTestId("discount-type-select").select(discount.type);
|
|
||||||
cy.getByTestId("discount-value-input").type(discount.value);
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select(
|
|
||||||
discount.level
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.getByTestId("submit-discount-button").click();
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle form cancellation", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Fill some data
|
|
||||||
cy.getByTestId("discount-code-input").type("CANCELTEST");
|
|
||||||
cy.getByTestId("discount-name-input").type("Cancel Test");
|
|
||||||
|
|
||||||
// Click cancel button
|
|
||||||
cy.getByTestId("cancel-discount-button").click();
|
|
||||||
|
|
||||||
// Should return to list page
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show empty state when no results found", () => {
|
|
||||||
// Search for non-existent code
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("NONEXISTENTCODE123");
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Check for empty state
|
|
||||||
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate back properly", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Wait for form to load completely
|
|
||||||
cy.getByTestId("discount-code-input").should("be.visible");
|
|
||||||
|
|
||||||
// Click cancel button
|
|
||||||
cy.getByTestId("cancel-discount-button").click();
|
|
||||||
|
|
||||||
// Should return to list page
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with API mocking
|
|
||||||
it("should handle API errors gracefully", () => {
|
|
||||||
// Mock API error
|
|
||||||
cy.intercept("POST", "**/discount/**", {
|
|
||||||
statusCode: 400,
|
|
||||||
body: { message: "کد تخفیف تکراری است" },
|
|
||||||
}).as("createError");
|
|
||||||
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
cy.getByTestId("discount-code-input").type("ERRORTEST");
|
|
||||||
cy.getByTestId("discount-name-input").type("Error Test");
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("10");
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select("invoice");
|
|
||||||
|
|
||||||
cy.getByTestId("submit-discount-button").click();
|
|
||||||
|
|
||||||
cy.wait("@createError");
|
|
||||||
// Error message should appear
|
|
||||||
cy.contains("خطا در ایجاد کد تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle loading states", () => {
|
|
||||||
// Mock slow API response
|
|
||||||
cy.intercept("POST", "**/discount/**", {
|
|
||||||
delay: 2000,
|
|
||||||
statusCode: 201,
|
|
||||||
body: { id: 1, code: "TEST", name: "Test" },
|
|
||||||
}).as("createSlow");
|
|
||||||
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
cy.getByTestId("discount-code-input").type("LOADTEST");
|
|
||||||
cy.getByTestId("discount-name-input").type("Load Test");
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("10");
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select("invoice");
|
|
||||||
|
|
||||||
cy.getByTestId("submit-discount-button").click();
|
|
||||||
|
|
||||||
// Check loading state
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.disabled");
|
|
||||||
|
|
||||||
cy.wait("@createSlow");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test mobile responsiveness
|
|
||||||
it("should work on mobile viewport", () => {
|
|
||||||
cy.viewport("iphone-6");
|
|
||||||
|
|
||||||
cy.getByTestId("create-discount-button").should("be.visible");
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
cy.contains("ایجاد کد تخفیف").should("be.visible");
|
|
||||||
|
|
||||||
// Form should be usable on mobile
|
|
||||||
cy.getByTestId("discount-code-input").type("MOBILETEST");
|
|
||||||
cy.getByTestId("discount-name-input").type("Mobile Test");
|
|
||||||
cy.getByTestId("discount-type-select").select("percentage");
|
|
||||||
cy.getByTestId("discount-value-input").type("10");
|
|
||||||
cy.getByTestId("discount-status-select").select("active");
|
|
||||||
cy.getByTestId("discount-application-level-select").select("invoice");
|
|
||||||
|
|
||||||
// Scroll to submit button to make it visible
|
|
||||||
cy.getByTestId("submit-discount-button").scrollIntoView();
|
|
||||||
cy.getByTestId("submit-discount-button").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test accessibility
|
|
||||||
it("should be accessible", () => {
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
|
|
||||||
// Check for proper labels
|
|
||||||
cy.get("label").should("have.length.greaterThan", 5);
|
|
||||||
|
|
||||||
// Check for required field indicators
|
|
||||||
cy.getByTestId("discount-code-input").should(
|
|
||||||
"have.attr",
|
|
||||||
"aria-required",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
cy.getByTestId("discount-name-input").should(
|
|
||||||
"have.attr",
|
|
||||||
"aria-required",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for proper form structure
|
|
||||||
cy.get("form").should("exist");
|
|
||||||
cy.get(".bg-gradient-to-r").should("have.length.greaterThan", 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
describe("Discount Codes Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display discount codes list page", () => {
|
|
||||||
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
|
|
||||||
cy.contains("ایجاد و مدیریت کدهای تخفیف").should("be.visible");
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to create discount code page", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.url().should("include", "/discount-codes/create");
|
|
||||||
cy.contains("ایجاد کد تخفیف").should("be.visible");
|
|
||||||
cy.contains("ایجاد و مدیریت کدهای تخفیف برای فروشگاه").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a percentage discount code", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Fill basic information
|
|
||||||
cy.get('input[name="code"]').type("SAVE20");
|
|
||||||
cy.get('input[name="name"]').type("تخفیف ۲۰ درصدی");
|
|
||||||
cy.get('textarea[name="description"]').type("تخفیف ۲۰ درصدی برای کل خرید");
|
|
||||||
|
|
||||||
// Set discount settings
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("20");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Set limits
|
|
||||||
cy.get('input[name="min_purchase_amount"]').type("100000");
|
|
||||||
cy.get('input[name="max_discount_amount"]').type("50000");
|
|
||||||
cy.get('input[name="usage_limit"]').type("1000");
|
|
||||||
cy.get('input[name="user_usage_limit"]').type("1");
|
|
||||||
|
|
||||||
// Set date range
|
|
||||||
cy.get('input[name="valid_from"]').type("2024-01-01T00:00");
|
|
||||||
cy.get('input[name="valid_to"]').type("2024-12-31T23:59");
|
|
||||||
|
|
||||||
// Set user restrictions
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select("loyal");
|
|
||||||
|
|
||||||
// Set meta information
|
|
||||||
cy.get('input[name="meta.campaign"]').type("summer_sale");
|
|
||||||
cy.get('input[name="meta.category"]').type("general");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify creation
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("SAVE20").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a fixed amount discount code", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Fill basic information
|
|
||||||
cy.get('input[name="code"]').type("FIXED50000");
|
|
||||||
cy.get('input[name="name"]').type("تخفیف ۵۰ هزار تومانی");
|
|
||||||
cy.get('textarea[name="description"]').type(
|
|
||||||
"تخفیف مبلغ ثابت ۵۰ هزار تومان"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set discount settings
|
|
||||||
cy.get('select[name="type"]').select("fixed");
|
|
||||||
cy.get('input[name="value"]').type("50000");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Set single use
|
|
||||||
cy.get('input[name="single_use"]').check();
|
|
||||||
|
|
||||||
// Set user restrictions for new users only
|
|
||||||
cy.get('input[name="user_restrictions.new_users_only"]').check();
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify creation
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("FIXED50000").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a fee percentage discount code", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Fill basic information
|
|
||||||
cy.get('input[name="code"]').type("FEEREDUCTION10");
|
|
||||||
cy.get('input[name="name"]').type("کاهش کارمزد ۱۰ درصدی");
|
|
||||||
|
|
||||||
// Set discount settings
|
|
||||||
cy.get('select[name="type"]').select("fee_percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="application_level"]').select("product_fee");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify creation
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("FEEREDUCTION10").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate required fields", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Try to submit without required fields
|
|
||||||
cy.get('button[type="submit"]').should("be.disabled");
|
|
||||||
|
|
||||||
// Fill only code field
|
|
||||||
cy.get('input[name="code"]').type("TEST");
|
|
||||||
cy.get('button[type="submit"]').should("be.disabled");
|
|
||||||
|
|
||||||
// Fill name field
|
|
||||||
cy.get('input[name="name"]').type("Test");
|
|
||||||
cy.get('button[type="submit"]').should("be.disabled");
|
|
||||||
|
|
||||||
// Fill all required fields
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Now submit button should be enabled
|
|
||||||
cy.get('button[type="submit"]').should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate code length", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Test code too short
|
|
||||||
cy.get('input[name="code"]').type("AB");
|
|
||||||
cy.get('input[name="name"]').type("Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
|
|
||||||
cy.contains("کد باید حداقل ۳ کاراکتر باشد").should("be.visible");
|
|
||||||
|
|
||||||
// Clear and test code too long
|
|
||||||
cy.get('input[name="code"]').clear().type("A".repeat(51));
|
|
||||||
cy.contains("کد نباید بیشتر از ۵۰ کاراکتر باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate percentage value range", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="code"]').type("TESTPERCENTAGE");
|
|
||||||
cy.get('input[name="name"]').type("Test Percentage");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
|
|
||||||
// Test value too high for percentage (should warn in UI for >100)
|
|
||||||
cy.get('input[name="value"]').type("150");
|
|
||||||
|
|
||||||
// The form should still accept it but backend will validate
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search and filter discount codes", () => {
|
|
||||||
// Assuming we have some discount codes in the list
|
|
||||||
cy.get('input[placeholder="جستجو بر اساس کد..."]').type("SAVE");
|
|
||||||
cy.wait(500); // Wait for search to filter
|
|
||||||
|
|
||||||
// Test status filter
|
|
||||||
cy.get("select").contains("همه وضعیتها").parent().select("active");
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Clear filters
|
|
||||||
cy.contains("پاک کردن فیلترها").click();
|
|
||||||
cy.get('input[placeholder="جستجو بر اساس کد..."]').should("have.value", "");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit existing discount code", () => {
|
|
||||||
// Assuming we have discount codes in the list
|
|
||||||
cy.get("table tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="ویرایش"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.url().should("include", "/discount-codes/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
cy.contains("ویرایش کد تخفیف").should("be.visible");
|
|
||||||
|
|
||||||
// Modify the discount code
|
|
||||||
cy.get('input[name="name"]').clear().type("کد تخفیف ویرایش شده");
|
|
||||||
cy.get('textarea[name="description"]').clear().type("توضیحات ویرایش شده");
|
|
||||||
|
|
||||||
// Submit changes
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify update
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
cy.contains("کد تخفیف ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete discount code", () => {
|
|
||||||
// Create a test discount code first
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
cy.get('input[name="code"]').type("TESTDELETE");
|
|
||||||
cy.get('input[name="name"]').type("Test Delete");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("10");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Wait for creation
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
|
|
||||||
// Find and delete the test discount code
|
|
||||||
cy.contains("TESTDELETE")
|
|
||||||
.parent()
|
|
||||||
.parent()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[title="حذف"]').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.contains("آیا از حذف این کد تخفیف اطمینان دارید؟").should("be.visible");
|
|
||||||
cy.contains("button", "حذف").click();
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
cy.contains("کد تخفیف با موفقیت حذف شد").should("be.visible");
|
|
||||||
cy.contains("TESTDELETE").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display proper status badges", () => {
|
|
||||||
// Check if status badges are displayed with correct colors
|
|
||||||
cy.get("table tbody tr").each(($row) => {
|
|
||||||
cy.wrap($row).within(() => {
|
|
||||||
cy.get("span")
|
|
||||||
.contains(/فعال|غیرفعال/)
|
|
||||||
.should("exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show empty state when no discount codes exist", () => {
|
|
||||||
// This test assumes a clean state or uses a filter that returns no results
|
|
||||||
cy.get('input[placeholder="جستجو بر اساس کد..."]').type("NONEXISTENTCODE");
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
cy.contains("هیچ کد تخفیفی یافت نشد").should("be.visible");
|
|
||||||
cy.contains("برای شروع یک کد تخفیف ایجاد کنید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cancel discount code creation", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Fill some data
|
|
||||||
cy.get('input[name="code"]').type("CANCELTEST");
|
|
||||||
cy.get('input[name="name"]').type("Cancel Test");
|
|
||||||
|
|
||||||
// Click cancel
|
|
||||||
cy.contains("button", "انصراف").click();
|
|
||||||
|
|
||||||
// Should return to list page
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle user restrictions properly", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Fill basic fields
|
|
||||||
cy.get('input[name="code"]').type("USERRESTRICT");
|
|
||||||
cy.get('input[name="name"]').type("User Restriction Test");
|
|
||||||
cy.get('select[name="type"]').select("percentage");
|
|
||||||
cy.get('input[name="value"]').type("15");
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
cy.get('select[name="application_level"]').select("invoice");
|
|
||||||
|
|
||||||
// Set user restrictions
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select("new");
|
|
||||||
cy.get('input[name="user_restrictions.min_purchase_count"]').type("0");
|
|
||||||
cy.get('input[name="user_restrictions.max_purchase_count"]').type("5");
|
|
||||||
cy.get('input[name="user_restrictions.referrer_user_id"]').type("123");
|
|
||||||
|
|
||||||
// Check warning about mutually exclusive options
|
|
||||||
cy.get('input[name="user_restrictions.new_users_only"]').check();
|
|
||||||
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
|
|
||||||
|
|
||||||
// Should show warning
|
|
||||||
cy.contains(
|
|
||||||
"new_users_only و loyal_users_only نمیتوانند همزمان فعال باشند"
|
|
||||||
).should("be.visible");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.contains("کد تخفیف با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate form sections are properly organized", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Verify all sections exist
|
|
||||||
cy.contains("اطلاعات اصلی کد تخفیف").should("be.visible");
|
|
||||||
cy.contains("تنظیمات تخفیف").should("be.visible");
|
|
||||||
cy.contains("بازه زمانی اعتبار").should("be.visible");
|
|
||||||
cy.contains("محدودیتهای کاربری").should("be.visible");
|
|
||||||
cy.contains("اطلاعات تکمیلی").should("be.visible");
|
|
||||||
|
|
||||||
// Verify form has proper styling
|
|
||||||
cy.get(".bg-gradient-to-r").should("have.length.greaterThan", 3);
|
|
||||||
cy.get(".rounded-xl").should("have.length.greaterThan", 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle back navigation properly", () => {
|
|
||||||
cy.get('[title="کد تخفیف جدید"]').click();
|
|
||||||
|
|
||||||
// Click back button
|
|
||||||
cy.contains("بازگشت").click();
|
|
||||||
|
|
||||||
// Should return to list page
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
cy.contains("مدیریت کدهای تخفیف").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
describe("Product Options Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/product-options");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display product options list page", () => {
|
|
||||||
cy.contains("مدیریت گزینههای محصول").should("be.visible");
|
|
||||||
cy.contains("تنظیمات گزینههای قابل انتخاب برای محصولات").should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new product option", () => {
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/product-options/create");
|
|
||||||
cy.contains("گزینه محصول جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Fill product option form
|
|
||||||
cy.get('input[name="name"]').type("رنگ");
|
|
||||||
cy.get('textarea[name="description"]').type("انتخاب رنگ محصول");
|
|
||||||
cy.get('select[name="type"]').select("color");
|
|
||||||
|
|
||||||
// Add option values
|
|
||||||
cy.get("button").contains("افزودن گزینه").click();
|
|
||||||
cy.get('input[name="values[0].name"]').type("قرمز");
|
|
||||||
cy.get('input[name="values[0].value"]').type("#ff0000");
|
|
||||||
|
|
||||||
cy.get("button").contains("افزودن گزینه").click();
|
|
||||||
cy.get('input[name="values[1].name"]').type("آبی");
|
|
||||||
cy.get('input[name="values[1].value"]').type("#0000ff");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/product-options");
|
|
||||||
cy.contains("گزینه محصول با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("رنگ").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit a product option", () => {
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/product-options/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
// Update option
|
|
||||||
cy.get('input[name="name"]').clear().type("سایز");
|
|
||||||
cy.get('textarea[name="description"]').clear().type("انتخاب سایز محصول");
|
|
||||||
|
|
||||||
// Update values
|
|
||||||
cy.get('input[name="values[0].name"]').clear().type("کوچک");
|
|
||||||
cy.get('input[name="values[0].value"]').clear().type("S");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/product-options");
|
|
||||||
cy.contains("گزینه محصول با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("سایز").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a product option", () => {
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("گزینه محصول با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search product options", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("رنگ");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "رنگ");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter by option type", () => {
|
|
||||||
cy.get('select[name="type"]').select("color");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "color");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate product option form", () => {
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').click();
|
|
||||||
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام گزینه الزامی است").should("be.visible");
|
|
||||||
cy.contains("نوع گزینه الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate option values", () => {
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="name"]').type("رنگ");
|
|
||||||
cy.get('select[name="type"]').select("color");
|
|
||||||
|
|
||||||
// Add empty value
|
|
||||||
cy.get("button").contains("افزودن گزینه").click();
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام گزینه الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove option value", () => {
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="name"]').type("سایز");
|
|
||||||
cy.get('select[name="type"]').select("text");
|
|
||||||
|
|
||||||
// Add two values
|
|
||||||
cy.get("button").contains("افزودن گزینه").click();
|
|
||||||
cy.get('input[name="values[0].name"]').type("کوچک");
|
|
||||||
|
|
||||||
cy.get("button").contains("افزودن گزینه").click();
|
|
||||||
cy.get('input[name="values[1].name"]').type("بزرگ");
|
|
||||||
|
|
||||||
// Remove first value
|
|
||||||
cy.get('[data-testid="remove-value-0"]').click();
|
|
||||||
|
|
||||||
// Should have only one value now
|
|
||||||
cy.get('input[name="values[0].name"]').should("have.value", "بزرگ");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show option usage in products", () => {
|
|
||||||
cy.get('[title="نمایش استفاده"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.contains("محصولات استفاده کننده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different option types", () => {
|
|
||||||
cy.get('[title="گزینه محصول جدید"]').click();
|
|
||||||
|
|
||||||
// Test color type
|
|
||||||
cy.get('select[name="type"]').select("color");
|
|
||||||
cy.get(".color-picker").should("be.visible");
|
|
||||||
|
|
||||||
// Test text type
|
|
||||||
cy.get('select[name="type"]').select("text");
|
|
||||||
cy.get('input[type="text"]').should("be.visible");
|
|
||||||
|
|
||||||
// Test number type
|
|
||||||
cy.get('select[name="type"]').select("number");
|
|
||||||
cy.get('input[type="number"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should duplicate product option", () => {
|
|
||||||
cy.get('[title="کپی"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/product-options/create");
|
|
||||||
cy.get('input[name="name"]').should("contain.value", "(کپی)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should export product options", () => {
|
|
||||||
cy.get("button").contains("خروجی").click();
|
|
||||||
|
|
||||||
// Should download file
|
|
||||||
cy.readFile("cypress/downloads/product-options.xlsx").should("exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
describe("Products - Advanced Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Product CRUD Operations", () => {
|
|
||||||
it("should create a new product with all fields", () => {
|
|
||||||
cy.visit("/products");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
|
|
||||||
// Fill basic product information
|
|
||||||
cy.get('input[name="name"]').type("تست محصول جدید");
|
|
||||||
cy.get('textarea[name="description"]').type("توضیحات کامل محصول تست");
|
|
||||||
cy.get('input[name="design_style"]').type("مدرن");
|
|
||||||
|
|
||||||
// Enable product
|
|
||||||
cy.get('input[name="enabled"]').check({ force: true });
|
|
||||||
|
|
||||||
// Set product type
|
|
||||||
cy.get('select[name="type"]').select("0");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify redirect and success message
|
|
||||||
cy.url().should("include", "/products");
|
|
||||||
cy.contains("تست محصول جدید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit an existing product", () => {
|
|
||||||
cy.visit("/products");
|
|
||||||
|
|
||||||
// Click edit on first product
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get(
|
|
||||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update product name
|
|
||||||
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify changes
|
|
||||||
cy.url().should("include", "/products");
|
|
||||||
cy.contains("محصول ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a product with confirmation", () => {
|
|
||||||
cy.visit("/products");
|
|
||||||
|
|
||||||
// Click delete on first product
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
// Verify success message
|
|
||||||
cy.contains("محصول با موفقیت حذف شد", { timeout: 10000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Product Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/products");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show validation errors for empty required fields", () => {
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Check for validation messages
|
|
||||||
cy.contains("نام محصول الزامی است", { timeout: 5000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate minimum length for product name", () => {
|
|
||||||
cy.get('input[name="name"]').type("a");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام محصول باید حداقل", { timeout: 5000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Product Search and Filter", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/products");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search products by name", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("تست");
|
|
||||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
|
||||||
|
|
||||||
// Wait for results
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check that search results contain the search term
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter products by category", () => {
|
|
||||||
cy.get('select[name="category_id"], select').first().select("1");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Results should be filtered
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Product Status Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/products");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle product status", () => {
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('input[type="checkbox"], .toggle')
|
|
||||||
.first()
|
|
||||||
.click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("وضعیت محصول با موفقیت تغییر کرد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
describe("Product Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/products");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display products list page", () => {
|
|
||||||
cy.contains("مدیریت محصولات").should("be.visible");
|
|
||||||
cy.contains("مدیریت محصولات، قیمتها و موجودی").should("be.visible");
|
|
||||||
cy.get('[title="محصول جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to create product page", () => {
|
|
||||||
cy.get('[title="محصول جدید"]').click();
|
|
||||||
cy.url().should("include", "/products/create");
|
|
||||||
cy.contains("محصول جدید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new product", () => {
|
|
||||||
cy.get('[title="محصول جدید"]').click();
|
|
||||||
|
|
||||||
// Fill product form
|
|
||||||
cy.get('input[name="name"]').type("محصول تست");
|
|
||||||
cy.get('textarea[name="description"]').type("توضیحات محصول تست");
|
|
||||||
cy.get('input[name="design_style"]').type("مدرن");
|
|
||||||
|
|
||||||
// Enable product
|
|
||||||
cy.get('input[name="enabled"]').check();
|
|
||||||
|
|
||||||
// Set product type
|
|
||||||
cy.get('select[name="type"]').select("0");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Should redirect to products list
|
|
||||||
cy.url().should("include", "/products");
|
|
||||||
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("محصول تست").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search products", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("تست");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
// Should filter results
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "تست");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter products by category", () => {
|
|
||||||
cy.get("select").first().select("1"); // Assuming category with id 1 exists
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
// Results should be filtered by category
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit a product", () => {
|
|
||||||
// Click edit button on first product
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/products/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
// Update product name
|
|
||||||
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Should redirect back to list
|
|
||||||
cy.url().should("include", "/products");
|
|
||||||
cy.contains("محصول با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("محصول ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a product", () => {
|
|
||||||
// Click delete button on first product
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("محصول با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should manage product variants", () => {
|
|
||||||
cy.get('[title="محصول جدید"]').click();
|
|
||||||
|
|
||||||
// Fill basic product info
|
|
||||||
cy.get('input[name="name"]').type("محصول با واریانت");
|
|
||||||
cy.get('textarea[name="description"]').type("محصول تست با واریانت");
|
|
||||||
|
|
||||||
// Add variant
|
|
||||||
cy.get("button").contains("افزودن واریانت").click();
|
|
||||||
|
|
||||||
// Fill variant details
|
|
||||||
cy.get('input[name="variants[0].enabled"]').check();
|
|
||||||
cy.get('input[name="variants[0].fee_percentage"]').type("10");
|
|
||||||
cy.get('input[name="variants[0].profit_percentage"]').type("20");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate product form", () => {
|
|
||||||
cy.get('[title="محصول جدید"]').click();
|
|
||||||
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Should show validation errors
|
|
||||||
cy.contains("نام محصول الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle pagination", () => {
|
|
||||||
// Assuming there are multiple pages of products
|
|
||||||
cy.get('[data-testid="pagination"]').should("be.visible");
|
|
||||||
|
|
||||||
// Go to next page
|
|
||||||
cy.get("button").contains("بعدی").click();
|
|
||||||
cy.waitForLoading();
|
|
||||||
|
|
||||||
// URL should change
|
|
||||||
cy.url().should("include", "page=2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should sort products", () => {
|
|
||||||
// Click on sortable column header
|
|
||||||
cy.get("th").contains("نام").click();
|
|
||||||
cy.waitForLoading();
|
|
||||||
|
|
||||||
// Should sort by name
|
|
||||||
cy.url().should("include", "sort=name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should export products list", () => {
|
|
||||||
cy.get("button").contains("خروجی").click();
|
|
||||||
|
|
||||||
// Should download file
|
|
||||||
cy.readFile("cypress/downloads/products.xlsx").should("exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
describe("Roles - Advanced Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role CRUD Operations", () => {
|
|
||||||
it("should create a new role", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
|
|
||||||
// Fill role information
|
|
||||||
cy.get('input[name="name"]').type("نقش تست");
|
|
||||||
cy.get('textarea[name="description"]').type("توضیحات نقش تست");
|
|
||||||
|
|
||||||
// Enable role
|
|
||||||
cy.get('input[name="enabled"]').check({ force: true });
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify redirect and success
|
|
||||||
cy.url().should("include", "/roles");
|
|
||||||
cy.contains("نقش تست").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit an existing role", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
|
|
||||||
// Click edit on first role
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get(
|
|
||||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update role name
|
|
||||||
cy.get('input[name="name"]').clear().type("نقش ویرایش شده");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify changes
|
|
||||||
cy.url().should("include", "/roles");
|
|
||||||
cy.contains("نقش ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a role with confirmation", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
|
|
||||||
// Click delete on first role (skip admin role)
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.eq(1)
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
// Verify success message
|
|
||||||
cy.contains("نقش با موفقیت حذف شد", { timeout: 10000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show validation errors for empty required fields", () => {
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Check for validation messages
|
|
||||||
cy.contains("نام نقش الزامی است", { timeout: 5000 }).should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate minimum length for role name", () => {
|
|
||||||
cy.get('input[name="name"]').type("a");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام نقش باید حداقل", { timeout: 5000 }).should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role Permissions Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should manage role permissions", () => {
|
|
||||||
// Click permissions on first role
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
|
|
||||||
.contains("مجوزها")
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should navigate to permissions page
|
|
||||||
cy.url().should("include", "/roles/");
|
|
||||||
cy.url().should("include", "/permissions");
|
|
||||||
cy.contains("مدیریت مجوزهای نقش").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should assign permissions to role", () => {
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
|
|
||||||
.contains("مجوزها")
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle some permissions
|
|
||||||
cy.get('input[type="checkbox"]').first().click({ force: true });
|
|
||||||
|
|
||||||
// Save changes
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("مجوزهای نقش با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role Search and Filter", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search roles by name", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
|
|
||||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
|
||||||
|
|
||||||
// Wait for results
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check search results
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter roles by status", () => {
|
|
||||||
cy.get('select[name="enabled"], select').first().select("true");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Results should be filtered
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role Status Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle role status", () => {
|
|
||||||
// Skip admin role, use second role
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.eq(1)
|
|
||||||
.within(() => {
|
|
||||||
cy.get('input[type="checkbox"], .toggle')
|
|
||||||
.first()
|
|
||||||
.click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("وضعیت نقش با موفقیت تغییر کرد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
describe("Roles and Permissions Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Roles Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display roles list page", () => {
|
|
||||||
cy.contains("مدیریت نقشها").should("be.visible");
|
|
||||||
cy.contains("مدیریت نقشها و دسترسیهای سیستم").should("be.visible");
|
|
||||||
cy.get('[title="نقش جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new role", () => {
|
|
||||||
cy.get('[title="نقش جدید"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles/create");
|
|
||||||
cy.contains("نقش جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Fill role form
|
|
||||||
cy.get('input[name="name"]').type("مدیر محصولات");
|
|
||||||
cy.get('textarea[name="description"]').type(
|
|
||||||
"مسئول مدیریت محصولات و کاتگوریها"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles");
|
|
||||||
cy.contains("نقش با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("مدیر محصولات").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit a role", () => {
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
cy.get('input[name="name"]').clear().type("مدیر فروش");
|
|
||||||
cy.get('textarea[name="description"]')
|
|
||||||
.clear()
|
|
||||||
.type("مسئول مدیریت فروش و سفارشات");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles");
|
|
||||||
cy.contains("نقش با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("مدیر فروش").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a role", () => {
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("نقش با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should view role details", () => {
|
|
||||||
cy.get('[title="مشاهده جزئیات"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles/");
|
|
||||||
cy.contains("جزئیات نقش").should("be.visible");
|
|
||||||
cy.contains("لیست کاربران").should("be.visible");
|
|
||||||
cy.contains("دسترسیها").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should manage role permissions", () => {
|
|
||||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/roles/");
|
|
||||||
cy.url().should("include", "/permissions");
|
|
||||||
|
|
||||||
cy.contains("مدیریت دسترسیهای نقش").should("be.visible");
|
|
||||||
|
|
||||||
// Assign permission
|
|
||||||
cy.get('input[type="checkbox"]').first().check();
|
|
||||||
cy.get("button").contains("ذخیره تغییرات").click();
|
|
||||||
|
|
||||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search roles", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("مدیر");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "مدیر");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate role form", () => {
|
|
||||||
cy.get('[title="نقش جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام نقش الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Permissions Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/permissions");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display permissions list page", () => {
|
|
||||||
cy.contains("لیست دسترسیها").should("be.visible");
|
|
||||||
cy.contains("نمایش دسترسیهای سیستم").should("be.visible");
|
|
||||||
cy.get('[title="دسترسی جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new permission", () => {
|
|
||||||
cy.get('[title="دسترسی جدید"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/permissions/create");
|
|
||||||
cy.contains("دسترسی جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Fill permission form
|
|
||||||
cy.get('input[name="title"]').type("مدیریت کاربران");
|
|
||||||
cy.get('textarea[name="description"]').type(
|
|
||||||
"دسترسی به مدیریت کاربران سیستم"
|
|
||||||
);
|
|
||||||
cy.get('input[name="resource"]').type("users");
|
|
||||||
cy.get('input[name="action"]').type("manage");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/permissions");
|
|
||||||
cy.contains("دسترسی با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("مدیریت کاربران").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit a permission", () => {
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/permissions/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
cy.get('input[name="title"]').clear().type("نمایش کاربران");
|
|
||||||
cy.get('input[name="action"]').clear().type("view");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/permissions");
|
|
||||||
cy.contains("دسترسی با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("نمایش کاربران").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a permission", () => {
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("دسترسی با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search permissions", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("کاربر");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "کاربر");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter permissions by resource", () => {
|
|
||||||
cy.get('select[name="resource"]').select("products");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "products");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate permission form", () => {
|
|
||||||
cy.get('[title="دسترسی جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("عنوان دسترسی الزامی است").should("be.visible");
|
|
||||||
cy.contains("منبع الزامی است").should("be.visible");
|
|
||||||
cy.contains("عمل الزامی است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show permission usage in roles", () => {
|
|
||||||
cy.get('[title="نمایش استفاده"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.contains("نقشهای دارای این دسترسی").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Role-Permission Assignment", () => {
|
|
||||||
it("should assign multiple permissions to role", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
|
||||||
|
|
||||||
// Select multiple permissions
|
|
||||||
cy.get('input[type="checkbox"]').check(["1", "2", "3"]);
|
|
||||||
cy.get("button").contains("ذخیره تغییرات").click();
|
|
||||||
|
|
||||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove permission from role", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
|
||||||
|
|
||||||
// Uncheck permission
|
|
||||||
cy.get('input[type="checkbox"]:checked').first().uncheck();
|
|
||||||
cy.get("button").contains("ذخیره تغییرات").click();
|
|
||||||
|
|
||||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show permission hierarchy", () => {
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
|
||||||
|
|
||||||
// Should show permissions grouped by category
|
|
||||||
cy.contains("کاربران").should("be.visible");
|
|
||||||
cy.contains("محصولات").should("be.visible");
|
|
||||||
cy.contains("سیستم").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
describe("Smoke Tests", () => {
|
|
||||||
it("should load the application", () => {
|
|
||||||
cy.visit("/login");
|
|
||||||
cy.contains("ورود به پنل مدیریت").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should complete basic user flow", () => {
|
|
||||||
// Login
|
|
||||||
cy.login();
|
|
||||||
|
|
||||||
// Navigate to dashboard
|
|
||||||
cy.visit("/");
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
|
|
||||||
// Check navigation works
|
|
||||||
cy.visit("/products");
|
|
||||||
cy.url().should("include", "/products");
|
|
||||||
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
|
|
||||||
cy.visit("/orders");
|
|
||||||
cy.url().should("include", "/orders");
|
|
||||||
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.url().should("include", "/users-admin");
|
|
||||||
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
cy.url().should("include", "/admin-users");
|
|
||||||
|
|
||||||
cy.visit("/roles");
|
|
||||||
cy.url().should("include", "/roles");
|
|
||||||
|
|
||||||
// Check logout works by visiting login page
|
|
||||||
cy.visit("/login");
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle API errors gracefully", () => {
|
|
||||||
cy.intercept("GET", "**/api/**", { statusCode: 500 }).as("apiError");
|
|
||||||
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/products");
|
|
||||||
|
|
||||||
cy.wait("@apiError");
|
|
||||||
// Check for loading or error state
|
|
||||||
cy.get("body").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work in different browsers", () => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/");
|
|
||||||
|
|
||||||
// Basic functionality should work
|
|
||||||
cy.contains("داشبورد").should("be.visible");
|
|
||||||
cy.get("header").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
/// <reference types="../support" />
|
|
||||||
|
|
||||||
describe("Users Admin Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display users admin list page", () => {
|
|
||||||
cy.contains("مدیریت کاربران").should("be.visible");
|
|
||||||
cy.getByTestId("create-user-button").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to create user page", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
cy.url().should("include", "/users-admin/create");
|
|
||||||
cy.contains("ایجاد کاربر جدید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new user", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Fill basic information
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
cy.getByTestId("last-name-input").type("احمدی");
|
|
||||||
cy.getByTestId("phone-number-input").type("09123456789");
|
|
||||||
cy.getByTestId("email-input").type("mohammad.ahmadi@example.com");
|
|
||||||
cy.getByTestId("national-code-input").type("1234567890");
|
|
||||||
cy.getByTestId("password-input").type("password123");
|
|
||||||
|
|
||||||
// Set verification status
|
|
||||||
cy.getByTestId("verified-true-radio").check();
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.getByTestId("submit-button").click();
|
|
||||||
|
|
||||||
// Verify creation
|
|
||||||
cy.url().should("include", "/users-admin/");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate required fields", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Submit button should be disabled initially
|
|
||||||
cy.getByTestId("submit-button").should("be.disabled");
|
|
||||||
|
|
||||||
// Fill only first name
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
cy.getByTestId("submit-button").should("be.disabled");
|
|
||||||
|
|
||||||
// Fill all required fields
|
|
||||||
cy.getByTestId("last-name-input").type("احمدی");
|
|
||||||
cy.getByTestId("phone-number-input").type("09123456789");
|
|
||||||
|
|
||||||
// Now submit button should be enabled
|
|
||||||
cy.getByTestId("submit-button").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate phone number format", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Test invalid phone number
|
|
||||||
cy.getByTestId("phone-number-input").type("123456");
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
cy.getByTestId("last-name-input").type("احمدی");
|
|
||||||
|
|
||||||
cy.get(".text-red-600").should("contain", "شماره تلفن معتبر نیست");
|
|
||||||
|
|
||||||
// Fix phone number
|
|
||||||
cy.getByTestId("phone-number-input").clear().type("09123456789");
|
|
||||||
cy.get(".text-red-600").should("not.contain", "شماره تلفن معتبر نیست");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate email format", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Test invalid email
|
|
||||||
cy.getByTestId("email-input").type("invalid-email");
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
|
|
||||||
cy.get(".text-red-600").should("contain", "ایمیل معتبر نیست");
|
|
||||||
|
|
||||||
// Fix email
|
|
||||||
cy.getByTestId("email-input").clear().type("valid@example.com");
|
|
||||||
cy.get(".text-red-600").should("not.contain", "ایمیل معتبر نیست");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search users", () => {
|
|
||||||
// Search by text
|
|
||||||
cy.getByTestId("search-users-input").type("محمد");
|
|
||||||
cy.getByTestId("search-button").click();
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
cy.getByTestId("clear-filters-button").click();
|
|
||||||
cy.getByTestId("search-users-input").should("have.value", "");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter users by status", () => {
|
|
||||||
// Filter by verified status
|
|
||||||
cy.getByTestId("status-filter-select").select("verified");
|
|
||||||
cy.getByTestId("search-button").click();
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Filter by unverified status
|
|
||||||
cy.getByTestId("status-filter-select").select("unverified");
|
|
||||||
cy.getByTestId("search-button").click();
|
|
||||||
cy.wait(500);
|
|
||||||
|
|
||||||
// Reset filter
|
|
||||||
cy.getByTestId("status-filter-select").select("all");
|
|
||||||
cy.getByTestId("search-button").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle user verification toggle", () => {
|
|
||||||
// Mock API response for users list
|
|
||||||
cy.intercept("GET", "**/users**", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی",
|
|
||||||
email: "mohammad@example.com",
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
}).as("getUsers");
|
|
||||||
|
|
||||||
// Mock verify API
|
|
||||||
cy.intercept("POST", "**/users/1/verify", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { message: "User verified successfully" },
|
|
||||||
}).as("verifyUser");
|
|
||||||
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.wait("@getUsers");
|
|
||||||
|
|
||||||
// Click verify button
|
|
||||||
cy.getByTestId("verify-user-1").click();
|
|
||||||
cy.wait("@verifyUser");
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
cy.contains("کاربر با موفقیت تأیید شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should view user details", () => {
|
|
||||||
// Mock API response
|
|
||||||
cy.intercept("GET", "**/users**", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی",
|
|
||||||
email: "mohammad@example.com",
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}).as("getUsers");
|
|
||||||
|
|
||||||
cy.intercept("GET", "**/users/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی",
|
|
||||||
email: "mohammad@example.com",
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
}).as("getUser");
|
|
||||||
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.wait("@getUsers");
|
|
||||||
|
|
||||||
// Click view button
|
|
||||||
cy.getByTestId("view-user-1").click();
|
|
||||||
cy.wait("@getUser");
|
|
||||||
|
|
||||||
cy.url().should("include", "/users-admin/1");
|
|
||||||
cy.contains("جزئیات کاربر").should("be.visible");
|
|
||||||
cy.contains("محمد احمدی").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit user", () => {
|
|
||||||
// Mock get user API
|
|
||||||
cy.intercept("GET", "**/users/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی",
|
|
||||||
email: "mohammad@example.com",
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
}).as("getUser");
|
|
||||||
|
|
||||||
// Mock update user API
|
|
||||||
cy.intercept("PUT", "**/users/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی ویرایش شده",
|
|
||||||
email: "mohammad.updated@example.com",
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
}).as("updateUser");
|
|
||||||
|
|
||||||
cy.visit("/users-admin/1/edit");
|
|
||||||
cy.wait("@getUser");
|
|
||||||
|
|
||||||
// Edit user information
|
|
||||||
cy.getByTestId("last-name-input").clear().type("احمدی ویرایش شده");
|
|
||||||
cy.getByTestId("email-input").clear().type("mohammad.updated@example.com");
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.getByTestId("submit-button").click();
|
|
||||||
cy.wait("@updateUser");
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
cy.contains("کاربر با موفقیت بهروزرسانی شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete user with confirmation", () => {
|
|
||||||
// Mock API responses
|
|
||||||
cy.intercept("GET", "**/users**", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
phone_number: "+989123456789",
|
|
||||||
first_name: "محمد",
|
|
||||||
last_name: "احمدی",
|
|
||||||
email: "mohammad@example.com",
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}).as("getUsers");
|
|
||||||
|
|
||||||
cy.intercept("DELETE", "**/users/1", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { message: "User deleted successfully" },
|
|
||||||
}).as("deleteUser");
|
|
||||||
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.wait("@getUsers");
|
|
||||||
|
|
||||||
// Click delete button
|
|
||||||
cy.getByTestId("delete-user-1").click();
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.contains("آیا از حذف کاربر").should("be.visible");
|
|
||||||
cy.contains("button", "حذف").click();
|
|
||||||
cy.wait("@deleteUser");
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
cy.contains("کاربر با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle form cancellation", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Fill some data
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
cy.getByTestId("last-name-input").type("احمدی");
|
|
||||||
|
|
||||||
// Click cancel
|
|
||||||
cy.getByTestId("cancel-button").click();
|
|
||||||
|
|
||||||
// Should return to list page
|
|
||||||
cy.url().should("include", "/users-admin");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show empty state when no users found", () => {
|
|
||||||
// Mock empty users response
|
|
||||||
cy.intercept("GET", "**/users**", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
users: [],
|
|
||||||
total: 0,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
}).as("getEmptyUsers");
|
|
||||||
|
|
||||||
cy.visit("/users-admin");
|
|
||||||
cy.wait("@getEmptyUsers");
|
|
||||||
|
|
||||||
cy.contains("هیچ کاربری یافت نشد").should("be.visible");
|
|
||||||
cy.contains("برای شروع یک کاربر ایجاد کنید").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work on mobile viewport", () => {
|
|
||||||
cy.viewport("iphone-6");
|
|
||||||
|
|
||||||
cy.getByTestId("create-user-button").should("be.visible");
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
cy.contains("ایجاد کاربر جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Form should be usable on mobile
|
|
||||||
cy.getByTestId("first-name-input").type("محمد");
|
|
||||||
cy.getByTestId("last-name-input").type("احمدی");
|
|
||||||
cy.getByTestId("phone-number-input").type("09123456789");
|
|
||||||
|
|
||||||
cy.getByTestId("submit-button").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be accessible", () => {
|
|
||||||
cy.getByTestId("create-user-button").click();
|
|
||||||
|
|
||||||
// Check for proper labels
|
|
||||||
cy.get("label").should("have.length.greaterThan", 5);
|
|
||||||
|
|
||||||
// Check for required field indicators
|
|
||||||
cy.getByTestId("first-name-input").should(
|
|
||||||
"have.attr",
|
|
||||||
"aria-required",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
cy.getByTestId("last-name-input").should(
|
|
||||||
"have.attr",
|
|
||||||
"aria-required",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for proper form structure
|
|
||||||
cy.get("form").should("exist");
|
|
||||||
cy.get(".bg-gradient-to-r").should("have.length.greaterThan", 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
describe("Users - Advanced Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User CRUD Operations", () => {
|
|
||||||
it("should create a new admin user", () => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
|
|
||||||
// Fill user information
|
|
||||||
cy.get('input[name="first_name"]').type("کاربر");
|
|
||||||
cy.get('input[name="last_name"]').type("تست");
|
|
||||||
cy.get('input[name="username"]').type("test-user-" + Date.now());
|
|
||||||
cy.get('input[name="password"]').type("Test123456");
|
|
||||||
cy.get('input[name="password_confirmation"]').type("Test123456");
|
|
||||||
|
|
||||||
// Enable user
|
|
||||||
cy.get('input[name="enabled"]').check({ force: true });
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify redirect
|
|
||||||
cy.url().should("include", "/admin-users");
|
|
||||||
cy.contains("کاربر تست").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit an existing user", () => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
|
|
||||||
// Click edit on first user
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get(
|
|
||||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user info
|
|
||||||
cy.get('input[name="first_name"]').clear().type("کاربر ویرایش شده");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Verify changes
|
|
||||||
cy.url().should("include", "/admin-users");
|
|
||||||
cy.contains("کاربر ویرایش شده").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete a user with confirmation", () => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
|
|
||||||
// Click delete on first user
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
// Verify success message
|
|
||||||
cy.contains("کاربر با موفقیت حذف شد", { timeout: 10000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Form Validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show validation errors for empty required fields", () => {
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Check for validation messages
|
|
||||||
cy.contains("نام الزامی است", { timeout: 5000 }).should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate password confirmation", () => {
|
|
||||||
cy.get('input[name="first_name"]').type("تست");
|
|
||||||
cy.get('input[name="last_name"]').type("کاربر");
|
|
||||||
cy.get('input[name="username"]').type("testuser");
|
|
||||||
cy.get('input[name="password"]').type("password123");
|
|
||||||
cy.get('input[name="password_confirmation"]').type("different");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("تأیید رمز عبور مطابقت ندارد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate minimum password length", () => {
|
|
||||||
cy.get('input[name="password"]').type("123");
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("رمز عبور باید حداقل", { timeout: 5000 }).should(
|
|
||||||
"be.visible"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Search and Filter", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search users by name", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
|
|
||||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
|
||||||
|
|
||||||
// Wait for results
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check search results
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter users by status", () => {
|
|
||||||
cy.get('select[name="enabled"], select').first().select("true");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Results should be filtered
|
|
||||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Status Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle user status", () => {
|
|
||||||
cy.get("tbody tr")
|
|
||||||
.first()
|
|
||||||
.within(() => {
|
|
||||||
cy.get('input[type="checkbox"], .toggle')
|
|
||||||
.first()
|
|
||||||
.click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Import/Export", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show import modal", () => {
|
|
||||||
cy.get("button").contains("وارد کردن").click();
|
|
||||||
|
|
||||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
|
||||||
cy.contains("وارد کردن کاربران از فایل Excel").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate file upload format", () => {
|
|
||||||
cy.get("button").contains("وارد کردن").click();
|
|
||||||
|
|
||||||
// Upload invalid file type
|
|
||||||
cy.get('input[type="file"]').selectFile(
|
|
||||||
"cypress/fixtures/invalid-file.txt",
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.contains("فرمت فایل باید xlsx باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
describe("User Management", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
cy.visit("/admin-users");
|
|
||||||
cy.waitForLoading();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display admin users list page", () => {
|
|
||||||
cy.contains("مدیریت کاربران ادمین").should("be.visible");
|
|
||||||
cy.contains("مدیریت کاربران دسترسی به پنل ادمین").should("be.visible");
|
|
||||||
cy.get('[title="کاربر ادمین جدید"]').should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a new admin user", () => {
|
|
||||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/admin-users/create");
|
|
||||||
cy.contains("کاربر ادمین جدید").should("be.visible");
|
|
||||||
|
|
||||||
// Fill user form
|
|
||||||
cy.get('input[name="first_name"]').type("احمد");
|
|
||||||
cy.get('input[name="last_name"]').type("محمدی");
|
|
||||||
cy.get('input[name="username"]').type("ahmad.mohammadi");
|
|
||||||
// Email field removed as admin users only need username
|
|
||||||
cy.get('input[name="password"]').type("password123");
|
|
||||||
// Phone field not available in admin user form
|
|
||||||
|
|
||||||
// Set status
|
|
||||||
cy.get('select[name="status"]').select("active");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/admin-users");
|
|
||||||
cy.contains("کاربر با موفقیت ایجاد شد").should("be.visible");
|
|
||||||
cy.contains("احمد محمدی").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search admin users", () => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').type("احمد");
|
|
||||||
cy.get("button").contains("جستجو").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.get("table tbody tr").should("contain", "احمد");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter users by role", () => {
|
|
||||||
cy.get("select").contains("نقش").select("مدیر");
|
|
||||||
cy.get("button").contains("اعمال فیلتر").click();
|
|
||||||
|
|
||||||
cy.waitForLoading();
|
|
||||||
// Results should be filtered by role
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should edit an admin user", () => {
|
|
||||||
cy.get('[title="ویرایش"]').first().click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/admin-users/");
|
|
||||||
cy.url().should("include", "/edit");
|
|
||||||
|
|
||||||
// Update user info
|
|
||||||
cy.get('input[name="first_name"]').clear().type("علی");
|
|
||||||
cy.get('input[name="last_name"]').clear().type("احمدی");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.url().should("include", "/admin-users");
|
|
||||||
cy.contains("کاربر با موفقیت ویرایش شد").should("be.visible");
|
|
||||||
cy.contains("علی احمدی").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete an admin user", () => {
|
|
||||||
cy.get('[title="حذف"]').first().click();
|
|
||||||
|
|
||||||
// Confirm deletion in modal
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.get("button").contains("حذف").click();
|
|
||||||
|
|
||||||
cy.contains("کاربر با موفقیت حذف شد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate admin user form", () => {
|
|
||||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
|
||||||
|
|
||||||
// Try to submit empty form
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
// Should show validation errors
|
|
||||||
cy.contains("نام الزامی است").should("be.visible");
|
|
||||||
cy.contains("نام خانوادگی الزامی است").should("be.visible");
|
|
||||||
cy.contains("نام کاربری الزامی است").should("be.visible");
|
|
||||||
// Email not required for admin users
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate username format", () => {
|
|
||||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
|
||||||
|
|
||||||
cy.get('input[name="username"]').type("ab"); // خیلی کوتاه
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام کاربری باید حداقل 3 کاراکتر باشد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should validate username uniqueness", () => {
|
|
||||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
|
||||||
|
|
||||||
// Fill form with existing username
|
|
||||||
cy.get('input[name="first_name"]').type("تست");
|
|
||||||
cy.get('input[name="last_name"]').type("کاربر");
|
|
||||||
cy.get('input[name="username"]').type("admin"); // Assuming 'admin' already exists
|
|
||||||
cy.get('input[name="password"]').type("password123");
|
|
||||||
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
|
|
||||||
cy.contains("نام کاربری قبلاً استفاده شده است").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle user status toggle", () => {
|
|
||||||
// Assuming there's a toggle for user status
|
|
||||||
cy.get('[data-testid="user-status-toggle"]').first().click();
|
|
||||||
|
|
||||||
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display user activity logs", () => {
|
|
||||||
cy.get('[title="لاگ فعالیت"]').first().click();
|
|
||||||
|
|
||||||
cy.get(".modal").should("be.visible");
|
|
||||||
cy.contains("لاگ فعالیت کاربر").should("be.visible");
|
|
||||||
cy.get("table").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# This would be a test image file
|
|
||||||
# For demo purposes, this represents an image placeholder
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# This is an invalid file format for testing file upload validation
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// ***********************************************
|
|
||||||
// This example commands.ts shows you how to
|
|
||||||
// create various custom commands and overwrite
|
|
||||||
// existing commands.
|
|
||||||
//
|
|
||||||
// For more comprehensive examples of custom
|
|
||||||
// commands please read more here:
|
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
|
||||||
|
|
||||||
Cypress.Commands.add("login", (username = "admin", password = "admin123") => {
|
|
||||||
cy.visit("/login");
|
|
||||||
cy.get('input[name="username"]').type(username);
|
|
||||||
cy.get('input[name="password"]').type(password);
|
|
||||||
cy.get('button[type="submit"]').click();
|
|
||||||
cy.url().should("not.include", "/login");
|
|
||||||
cy.contains("داشبورد", { timeout: 10000 }).should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add("logout", () => {
|
|
||||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
|
||||||
cy.contains("خروج").click();
|
|
||||||
cy.url().should("include", "/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add("getByTestId", (testId: string) => {
|
|
||||||
return cy.get(`[data-testid="${testId}"]`);
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add("waitForLoading", () => {
|
|
||||||
// Wait for any loading spinner to disappear
|
|
||||||
cy.get(".animate-spin", { timeout: 1000 }).should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import discount codes helpers
|
|
||||||
import "./discount-codes-helpers";
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// ***********************************************************
|
|
||||||
// This example support/component.ts is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import "./commands";
|
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
|
||||||
// require('./commands')
|
|
||||||
|
|
||||||
import { mount } from "cypress/react18";
|
|
||||||
|
|
||||||
// Augment the Cypress namespace to include type definitions for
|
|
||||||
// your custom command.
|
|
||||||
// Alternatively, you can type this at the top of your test file.
|
|
||||||
declare global {
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
mount: typeof mount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Cypress.Commands.add("mount", mount);
|
|
||||||
|
|
||||||
// Example use:
|
|
||||||
// cy.mount(<MyComponent />)
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
// Helper functions for discount codes E2E tests
|
|
||||||
|
|
||||||
export interface DiscountCodeData {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
type: "percentage" | "fixed" | "fee_percentage";
|
|
||||||
value: string;
|
|
||||||
status: "active" | "inactive";
|
|
||||||
applicationLevel:
|
|
||||||
| "invoice"
|
|
||||||
| "category"
|
|
||||||
| "product"
|
|
||||||
| "shipping"
|
|
||||||
| "product_fee";
|
|
||||||
minPurchaseAmount?: string;
|
|
||||||
maxDiscountAmount?: string;
|
|
||||||
usageLimit?: string;
|
|
||||||
userUsageLimit?: string;
|
|
||||||
singleUse?: boolean;
|
|
||||||
validFrom?: string;
|
|
||||||
validTo?: string;
|
|
||||||
userGroup?: "new" | "loyal" | "all";
|
|
||||||
newUsersOnly?: boolean;
|
|
||||||
loyalUsersOnly?: boolean;
|
|
||||||
campaign?: string;
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
createDiscountCode(data: DiscountCodeData): Chainable<void>;
|
|
||||||
fillBasicDiscountInfo(data: Partial<DiscountCodeData>): Chainable<void>;
|
|
||||||
fillDiscountSettings(data: Partial<DiscountCodeData>): Chainable<void>;
|
|
||||||
fillUserRestrictions(data: Partial<DiscountCodeData>): Chainable<void>;
|
|
||||||
submitDiscountForm(): Chainable<void>;
|
|
||||||
verifyDiscountCreation(): Chainable<void>;
|
|
||||||
navigateToCreateDiscount(): Chainable<void>;
|
|
||||||
searchDiscountCode(code: string): Chainable<void>;
|
|
||||||
clearDiscountFilters(): Chainable<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to create discount page
|
|
||||||
Cypress.Commands.add("navigateToCreateDiscount", () => {
|
|
||||||
cy.visit("/discount-codes");
|
|
||||||
cy.waitForLoading();
|
|
||||||
cy.getByTestId("create-discount-button").click();
|
|
||||||
cy.url().should("include", "/discount-codes/create");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fill basic discount information
|
|
||||||
Cypress.Commands.add(
|
|
||||||
"fillBasicDiscountInfo",
|
|
||||||
(data: Partial<DiscountCodeData>) => {
|
|
||||||
if (data.code) {
|
|
||||||
cy.getByTestId("discount-code-input").clear().type(data.code);
|
|
||||||
}
|
|
||||||
if (data.name) {
|
|
||||||
cy.getByTestId("discount-name-input").clear().type(data.name);
|
|
||||||
}
|
|
||||||
if (data.description) {
|
|
||||||
cy.getByTestId("discount-description-textarea")
|
|
||||||
.clear()
|
|
||||||
.type(data.description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fill discount settings
|
|
||||||
Cypress.Commands.add(
|
|
||||||
"fillDiscountSettings",
|
|
||||||
(data: Partial<DiscountCodeData>) => {
|
|
||||||
if (data.type) {
|
|
||||||
cy.getByTestId("discount-type-select").select(data.type);
|
|
||||||
}
|
|
||||||
if (data.value) {
|
|
||||||
cy.getByTestId("discount-value-input").clear().type(data.value);
|
|
||||||
}
|
|
||||||
if (data.status) {
|
|
||||||
cy.getByTestId("discount-status-select").select(data.status);
|
|
||||||
}
|
|
||||||
if (data.applicationLevel) {
|
|
||||||
cy.getByTestId("discount-application-level-select").select(
|
|
||||||
data.applicationLevel
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (data.minPurchaseAmount) {
|
|
||||||
cy.get('input[name="min_purchase_amount"]')
|
|
||||||
.clear()
|
|
||||||
.type(data.minPurchaseAmount);
|
|
||||||
}
|
|
||||||
if (data.maxDiscountAmount) {
|
|
||||||
cy.get('input[name="max_discount_amount"]')
|
|
||||||
.clear()
|
|
||||||
.type(data.maxDiscountAmount);
|
|
||||||
}
|
|
||||||
if (data.usageLimit) {
|
|
||||||
cy.get('input[name="usage_limit"]').clear().type(data.usageLimit);
|
|
||||||
}
|
|
||||||
if (data.userUsageLimit) {
|
|
||||||
cy.get('input[name="user_usage_limit"]')
|
|
||||||
.clear()
|
|
||||||
.type(data.userUsageLimit);
|
|
||||||
}
|
|
||||||
if (data.singleUse) {
|
|
||||||
cy.get('input[name="single_use"]').check();
|
|
||||||
}
|
|
||||||
if (data.validFrom) {
|
|
||||||
cy.get('input[name="valid_from"]').type(data.validFrom);
|
|
||||||
}
|
|
||||||
if (data.validTo) {
|
|
||||||
cy.get('input[name="valid_to"]').type(data.validTo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fill user restrictions
|
|
||||||
Cypress.Commands.add(
|
|
||||||
"fillUserRestrictions",
|
|
||||||
(data: Partial<DiscountCodeData>) => {
|
|
||||||
if (data.userGroup) {
|
|
||||||
cy.get('select[name="user_restrictions.user_group"]').select(
|
|
||||||
data.userGroup
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (data.newUsersOnly) {
|
|
||||||
cy.get('input[name="user_restrictions.new_users_only"]').check();
|
|
||||||
}
|
|
||||||
if (data.loyalUsersOnly) {
|
|
||||||
cy.get('input[name="user_restrictions.loyal_users_only"]').check();
|
|
||||||
}
|
|
||||||
if (data.campaign) {
|
|
||||||
cy.get('input[name="meta.campaign"]').clear().type(data.campaign);
|
|
||||||
}
|
|
||||||
if (data.category) {
|
|
||||||
cy.get('input[name="meta.category"]').clear().type(data.category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit discount form
|
|
||||||
Cypress.Commands.add("submitDiscountForm", () => {
|
|
||||||
cy.getByTestId("submit-discount-button").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify discount creation
|
|
||||||
Cypress.Commands.add("verifyDiscountCreation", () => {
|
|
||||||
cy.url().should("include", "/discount-codes");
|
|
||||||
cy.url().should("not.include", "/create");
|
|
||||||
cy.url().should("not.include", "/edit");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create complete discount code
|
|
||||||
Cypress.Commands.add("createDiscountCode", (data: DiscountCodeData) => {
|
|
||||||
cy.navigateToCreateDiscount();
|
|
||||||
cy.fillBasicDiscountInfo(data);
|
|
||||||
cy.fillDiscountSettings(data);
|
|
||||||
cy.fillUserRestrictions(data);
|
|
||||||
cy.submitDiscountForm();
|
|
||||||
cy.verifyDiscountCreation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search for discount code
|
|
||||||
Cypress.Commands.add("searchDiscountCode", (code: string) => {
|
|
||||||
cy.get('input[placeholder*="جستجو"]').clear().type(code);
|
|
||||||
cy.wait(500); // Wait for search to filter
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear discount filters
|
|
||||||
Cypress.Commands.add("clearDiscountFilters", () => {
|
|
||||||
cy.contains("پاک کردن فیلترها").click();
|
|
||||||
cy.get('input[placeholder*="جستجو"]').should("have.value", "");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Predefined discount code templates for testing
|
|
||||||
export const discountTemplates = {
|
|
||||||
basicPercentage: {
|
|
||||||
code: "BASIC20",
|
|
||||||
name: "Basic 20% Discount",
|
|
||||||
description: "Basic percentage discount for testing",
|
|
||||||
type: "percentage" as const,
|
|
||||||
value: "20",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "invoice" as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
fixedAmount: {
|
|
||||||
code: "FIXED50K",
|
|
||||||
name: "Fixed 50K Discount",
|
|
||||||
description: "Fixed amount discount for testing",
|
|
||||||
type: "fixed" as const,
|
|
||||||
value: "50000",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "invoice" as const,
|
|
||||||
minPurchaseAmount: "100000",
|
|
||||||
},
|
|
||||||
|
|
||||||
feePercentage: {
|
|
||||||
code: "FEERED10",
|
|
||||||
name: "Fee Reduction 10%",
|
|
||||||
description: "Fee percentage reduction for testing",
|
|
||||||
type: "fee_percentage" as const,
|
|
||||||
value: "10",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "product_fee" as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
loyalUsers: {
|
|
||||||
code: "LOYAL25",
|
|
||||||
name: "Loyal Users 25%",
|
|
||||||
description: "Discount for loyal users only",
|
|
||||||
type: "percentage" as const,
|
|
||||||
value: "25",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "invoice" as const,
|
|
||||||
userGroup: "loyal" as const,
|
|
||||||
loyalUsersOnly: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
newUsers: {
|
|
||||||
code: "WELCOME15",
|
|
||||||
name: "Welcome New Users",
|
|
||||||
description: "Welcome discount for new users",
|
|
||||||
type: "percentage" as const,
|
|
||||||
value: "15",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "invoice" as const,
|
|
||||||
userGroup: "new" as const,
|
|
||||||
newUsersOnly: true,
|
|
||||||
singleUse: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
timeBasedDiscount: {
|
|
||||||
code: "SUMMER24",
|
|
||||||
name: "Summer Sale 2024",
|
|
||||||
description: "Summer sale discount with time constraints",
|
|
||||||
type: "percentage" as const,
|
|
||||||
value: "30",
|
|
||||||
status: "active" as const,
|
|
||||||
applicationLevel: "invoice" as const,
|
|
||||||
validFrom: "2024-06-01T00:00",
|
|
||||||
validTo: "2024-08-31T23:59",
|
|
||||||
usageLimit: "1000",
|
|
||||||
userUsageLimit: "1",
|
|
||||||
campaign: "summer_sale_2024",
|
|
||||||
category: "seasonal",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// API response mocks
|
|
||||||
export const apiMocks = {
|
|
||||||
successfulCreation: (data: Partial<DiscountCodeData>) => ({
|
|
||||||
statusCode: 201,
|
|
||||||
body: {
|
|
||||||
id: Math.floor(Math.random() * 1000),
|
|
||||||
code: data.code,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
type: data.type,
|
|
||||||
value: parseFloat(data.value || "0"),
|
|
||||||
status: data.status,
|
|
||||||
application_level: data.applicationLevel,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
validationError: {
|
|
||||||
statusCode: 400,
|
|
||||||
body: {
|
|
||||||
message: "کد تخفیف تکراری است",
|
|
||||||
errors: {
|
|
||||||
code: ["این کد قبلاً استفاده شده است"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
serverError: {
|
|
||||||
statusCode: 500,
|
|
||||||
body: {
|
|
||||||
message: "خطای سرور",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
discountsList: {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
discount_codes: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
code: "SAVE20",
|
|
||||||
name: "20% Off Discount",
|
|
||||||
description: "Get 20% off on your purchase",
|
|
||||||
type: "percentage",
|
|
||||||
value: 20,
|
|
||||||
status: "active",
|
|
||||||
application_level: "invoice",
|
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
total_pages: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// ***********************************************************
|
|
||||||
// This example support/e2e.ts is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import "./commands";
|
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
|
||||||
// require('./commands')
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
login(username?: string, password?: string): Chainable<void>;
|
|
||||||
logout(): Chainable<void>;
|
|
||||||
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
|
|
||||||
waitForLoading(): Chainable<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
declare namespace Cypress {
|
|
||||||
interface Chainable {
|
|
||||||
login(username?: string, password?: string): Chainable<void>;
|
|
||||||
logout(): Chainable<void>;
|
|
||||||
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
|
|
||||||
waitForLoading(): Chainable<void>;
|
|
||||||
|
|
||||||
// Discount codes helper methods
|
|
||||||
navigateToCreateDiscount(): Chainable<void>;
|
|
||||||
fillBasicDiscountInfo(data: any): Chainable<void>;
|
|
||||||
fillDiscountSettings(data: any): Chainable<void>;
|
|
||||||
fillUserRestrictions(data: any): Chainable<void>;
|
|
||||||
submitDiscountForm(): Chainable<void>;
|
|
||||||
verifyDiscountCreation(): Chainable<void>;
|
|
||||||
createDiscountCode(data: any): Chainable<void>;
|
|
||||||
searchDiscountCode(code: string): Chainable<void>;
|
|
||||||
clearDiscountFilters(): Chainable<void>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,9 +0,0 @@
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
mazane-stage-backoffice:
|
|
||||||
image: mazane-backoffice:latest
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3099:80"
|
|
||||||
|
|
||||||
# docker compose -p mazane_stage_backoffice -f ./docker-compose.stage.yml up -d
|
|
||||||
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
|
|
@ -7,55 +7,38 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"swagger": "python3 -m http.server 8000 && open http://localhost:8000/swagger-ui.html",
|
|
||||||
"cypress:open": "cypress open",
|
|
||||||
"cypress:run": "cypress run",
|
|
||||||
"cypress:run:headless": "cypress run --headless",
|
|
||||||
"test:e2e": "start-server-and-test dev http://localhost:5173/ cypress:run",
|
|
||||||
"test:e2e:open": "start-server-and-test dev http://localhost:5173/ cypress:open"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@tanstack/react-query-devtools": "^5.80.6",
|
"@tanstack/react-query-devtools": "^5.80.6",
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"apexcharts": "^5.3.6",
|
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
|
||||||
"lucide-react": "^0.263.1",
|
"lucide-react": "^0.263.1",
|
||||||
"react": "^19.2.3",
|
"react": "^18.2.0",
|
||||||
"react-apexcharts": "^1.9.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-date-object": "2.1.9",
|
|
||||||
"react-dom": "^19.2.3",
|
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-multi-date-picker": "4.5.2",
|
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.15.0",
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"yup": "^1.6.1",
|
"yup": "^1.6.1",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/react18": "^2.0.1",
|
|
||||||
"@cypress/vite-dev-server": "^6.0.3",
|
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"cypress": "^14.5.3",
|
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.45.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
"postcss": "^8.4.27",
|
"postcss": "^8.4.27",
|
||||||
"start-server-and-test": "^2.0.12",
|
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"terser": "^5.43.1",
|
|
||||||
"typescript": "^5.0.2",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^4.4.5"
|
"vite": "^4.4.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
185
src/App.tsx
185
src/App.tsx
|
|
@ -1,102 +1,23 @@
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { Suspense, lazy } from 'react';
|
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||||
import { queryClient } from './lib/queryClient';
|
import { queryClient } from './lib/queryClient';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
|
import { Login } from './pages/Login';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Users } from './pages/Users';
|
||||||
|
import Products from './pages/Products';
|
||||||
|
import { Orders } from './pages/Orders';
|
||||||
|
import { Reports } from './pages/Reports';
|
||||||
|
import { Notifications } from './pages/Notifications';
|
||||||
import { Layout } from './components/layout/Layout';
|
import { Layout } from './components/layout/Layout';
|
||||||
|
|
||||||
// Lazy load pages for better code splitting
|
const ProtectedRoute = ({ children }: { children: any }) => {
|
||||||
const Login = lazy(() => import('./pages/Login').then(module => ({ default: module.Login })));
|
const { user } = useAuth();
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
|
|
||||||
const Users = lazy(() => import('./pages/Users').then(module => ({ default: module.Users })));
|
|
||||||
const Reports = lazy(() => import('./pages/Reports').then(module => ({ default: module.Reports })));
|
|
||||||
const Notifications = lazy(() => import('./pages/Notifications').then(module => ({ default: module.Notifications })));
|
|
||||||
|
|
||||||
// Lazy load admin pages for better code splitting
|
|
||||||
// Roles Pages
|
|
||||||
const RolesListPage = lazy(() => import('./pages/roles/roles-list/RolesListPage'));
|
|
||||||
const RoleFormPage = lazy(() => import('./pages/roles/role-form/RoleFormPage'));
|
|
||||||
const RoleDetailPage = lazy(() => import('./pages/roles/role-detail/RoleDetailPage'));
|
|
||||||
const RolePermissionsPage = lazy(() => import('./pages/roles/role-permissions/RolePermissionsPage'));
|
|
||||||
|
|
||||||
// Admin Users Pages
|
|
||||||
const AdminUsersListPage = lazy(() => import('./pages/admin-users/admin-users-list/AdminUsersListPage'));
|
|
||||||
const AdminUserFormPage = lazy(() => import('./pages/admin-users/admin-user-form/AdminUserFormPage'));
|
|
||||||
const AdminUserDetailPage = lazy(() => import('./pages/admin-users/admin-user-detail/AdminUserDetailPage'));
|
|
||||||
|
|
||||||
// Permissions Pages
|
|
||||||
const PermissionsListPage = lazy(() => import('./pages/permissions/permissions-list/PermissionsListPage'));
|
|
||||||
const PermissionFormPage = lazy(() => import('./pages/permissions/permission-form/PermissionFormPage'));
|
|
||||||
|
|
||||||
// Product Options Pages
|
|
||||||
const ProductOptionsListPage = lazy(() => import('./pages/product-options/product-options-list/ProductOptionsListPage'));
|
|
||||||
const ProductOptionFormPage = lazy(() => import('./pages/product-options/product-option-form/ProductOptionFormPage'));
|
|
||||||
|
|
||||||
// Categories Pages
|
|
||||||
const CategoriesListPage = lazy(() => import('./pages/categories/categories-list/CategoriesListPage'));
|
|
||||||
const CategoryFormPage = lazy(() => import('./pages/categories/category-form/CategoryFormPage'));
|
|
||||||
|
|
||||||
// Discount Codes Pages
|
|
||||||
const DiscountCodesListPage = lazy(() => import('./pages/discount-codes/discount-codes-list/DiscountCodesListPage'));
|
|
||||||
const DiscountCodeFormPage = lazy(() => import('./pages/discount-codes/discount-code-form/DiscountCodeFormPage'));
|
|
||||||
|
|
||||||
// Orders Pages
|
|
||||||
const OrdersListPage = lazy(() => import('./pages/orders/orders-list/OrdersListPage'));
|
|
||||||
const OrderDetailPage = lazy(() => import('./pages/orders/order-detail/OrderDetailPage'));
|
|
||||||
|
|
||||||
// Users Admin Pages
|
|
||||||
const UsersAdminListPage = lazy(() => import('./pages/users-admin/users-admin-list/UsersAdminListPage'));
|
|
||||||
const UserAdminDetailPage = lazy(() => import('./pages/users-admin/user-admin-detail/UserAdminDetailPage'));
|
|
||||||
const UserAdminFormPage = lazy(() => import('./pages/users-admin/user-admin-form/UserAdminFormPage'));
|
|
||||||
|
|
||||||
// Products Pages
|
|
||||||
const ProductsListPage = lazy(() => import('./pages/products/products-list/ProductsListPage'));
|
|
||||||
const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage'));
|
|
||||||
const ProductDetailPage = lazy(() => import('./pages/products/product-detail/ProductDetailPage'));
|
|
||||||
|
|
||||||
// Landing Hero Page
|
|
||||||
const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage'));
|
|
||||||
|
|
||||||
// Shipping Methods Pages
|
|
||||||
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
|
|
||||||
const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage'));
|
|
||||||
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
|
|
||||||
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
|
|
||||||
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
|
|
||||||
const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage'));
|
|
||||||
|
|
||||||
// Payment IPG Page
|
|
||||||
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
|
|
||||||
|
|
||||||
// Payment Card Page
|
|
||||||
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
|
|
||||||
|
|
||||||
// Wallet Page
|
|
||||||
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
|
|
||||||
|
|
||||||
// Reports Pages
|
|
||||||
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
|
|
||||||
const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-statistics/customer-discount-usage/CustomerDiscountUsagePage'));
|
|
||||||
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage'));
|
|
||||||
const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage'));
|
|
||||||
|
|
||||||
// Product Comments Page
|
|
||||||
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
|
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactElement }) => {
|
|
||||||
const { user, isLoading } = useAuth();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Layout />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user ? children : <Navigate to="/login" replace />;
|
return user ? children : <Navigate to="/login" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,94 +32,16 @@ const AppRoutes = () => {
|
||||||
}>
|
}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="products" element={<ProductsListPage />} />
|
<Route path="products" element={<Products />} />
|
||||||
|
<Route path="orders" element={<Orders />} />
|
||||||
<Route path="reports" element={<Reports />} />
|
<Route path="reports" element={<Reports />} />
|
||||||
<Route path="notifications" element={<Notifications />} />
|
<Route path="notifications" element={<Notifications />} />
|
||||||
|
|
||||||
{/* Roles Routes */}
|
|
||||||
<Route path="roles" element={<RolesListPage />} />
|
|
||||||
<Route path="roles/create" element={<RoleFormPage />} />
|
|
||||||
<Route path="roles/:id" element={<RoleDetailPage />} />
|
|
||||||
<Route path="roles/:id/edit" element={<RoleFormPage />} />
|
|
||||||
<Route path="roles/:id/permissions" element={<RolePermissionsPage />} />
|
|
||||||
|
|
||||||
{/* Admin Users Routes */}
|
|
||||||
<Route path="admin-users" element={<AdminUsersListPage />} />
|
|
||||||
<Route path="admin-users/create" element={<AdminUserFormPage />} />
|
|
||||||
<Route path="admin-users/:id" element={<AdminUserDetailPage />} />
|
|
||||||
<Route path="admin-users/:id/edit" element={<AdminUserFormPage />} />
|
|
||||||
|
|
||||||
{/* Permissions Routes */}
|
|
||||||
<Route path="permissions" element={<PermissionsListPage />} />
|
|
||||||
<Route path="permissions/create" element={<PermissionFormPage />} />
|
|
||||||
<Route path="permissions/:id/edit" element={<PermissionFormPage />} />
|
|
||||||
|
|
||||||
{/* Product Options Routes */}
|
|
||||||
<Route path="product-options" element={<ProductOptionsListPage />} />
|
|
||||||
<Route path="product-options/create" element={<ProductOptionFormPage />} />
|
|
||||||
<Route path="product-options/:id/edit" element={<ProductOptionFormPage />} />
|
|
||||||
|
|
||||||
{/* Categories Routes */}
|
|
||||||
<Route path="categories" element={<CategoriesListPage />} />
|
|
||||||
<Route path="categories/create" element={<CategoryFormPage />} />
|
|
||||||
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
|
|
||||||
|
|
||||||
{/* Discount Codes Routes */}
|
|
||||||
<Route path="discount-codes" element={<DiscountCodesListPage />} />
|
|
||||||
<Route path="discount-codes/create" element={<DiscountCodeFormPage />} />
|
|
||||||
<Route path="discount-codes/:id/edit" element={<DiscountCodeFormPage />} />
|
|
||||||
|
|
||||||
{/* Orders Routes */}
|
|
||||||
<Route path="orders" element={<OrdersListPage />} />
|
|
||||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
|
||||||
|
|
||||||
{/* Users Admin Routes */}
|
|
||||||
<Route path="users-admin" element={<UsersAdminListPage />} />
|
|
||||||
<Route path="users-admin/create" element={<UserAdminFormPage />} />
|
|
||||||
<Route path="users-admin/:id" element={<UserAdminDetailPage />} />
|
|
||||||
<Route path="users-admin/:id/edit" element={<UserAdminFormPage />} />
|
|
||||||
|
|
||||||
{/* Landing Hero Route */}
|
|
||||||
<Route path="landing-hero" element={<HeroSliderPage />} />
|
|
||||||
|
|
||||||
{/* Shipping Methods Routes */}
|
|
||||||
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
|
|
||||||
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
|
||||||
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
|
|
||||||
<Route path="shipping-methods/shipments-report" element={<ShipmentsByMethodReportPage />} />
|
|
||||||
|
|
||||||
<Route path="tickets" element={<TicketsListPage />} />
|
|
||||||
<Route path="tickets/config" element={<TicketConfigPage />} />
|
|
||||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
|
||||||
|
|
||||||
<Route path="contact-us" element={<ContactUsListPage />} />
|
|
||||||
|
|
||||||
{/* Products Routes */}
|
|
||||||
<Route path="products/create" element={<ProductFormPage />} />
|
|
||||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
|
||||||
<Route path="products/:id/edit" element={<ProductFormPage />} />
|
|
||||||
<Route path="products/comments" element={<ProductCommentsListPage />} />
|
|
||||||
|
|
||||||
{/* Payment IPG Route */}
|
|
||||||
<Route path="payment-ipg" element={<IPGListPage />} />
|
|
||||||
|
|
||||||
{/* Payment Card Route */}
|
|
||||||
<Route path="payment-card" element={<CardFormPage />} />
|
|
||||||
|
|
||||||
{/* Wallet Route */}
|
|
||||||
<Route path="wallet" element={<WalletListPage />} />
|
|
||||||
|
|
||||||
{/* Reports Routes */}
|
|
||||||
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
|
|
||||||
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
|
|
||||||
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
|
|
||||||
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
@ -206,9 +49,7 @@ const App = () => {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</Suspense>
|
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
@ -217,6 +58,6 @@ const App = () => {
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactApexChart from 'react-apexcharts';
|
|
||||||
import type { ApexOptions } from 'apexcharts';
|
|
||||||
import { CardTitle } from '../ui/Typography';
|
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
interface ApexAreaChartCardProps {
|
|
||||||
data: { name: string; value: number }[];
|
|
||||||
title?: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value: number | string) => {
|
|
||||||
const formatted = formatWithThousands(value);
|
|
||||||
return englishToPersian(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ApexAreaChartCard = ({ data, title, color = '#3b82f6' }: ApexAreaChartCardProps) => {
|
|
||||||
const categories = data.map((item) => item.name);
|
|
||||||
const series = [
|
|
||||||
{
|
|
||||||
name: title || '',
|
|
||||||
data: data.map((item) => item.value),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const options: ApexOptions = {
|
|
||||||
chart: {
|
|
||||||
type: 'area',
|
|
||||||
height: 250,
|
|
||||||
toolbar: { show: false },
|
|
||||||
zoom: { enabled: false },
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
},
|
|
||||||
colors: [color],
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
stroke: {
|
|
||||||
curve: 'smooth',
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
type: 'gradient',
|
|
||||||
gradient: {
|
|
||||||
shadeIntensity: 1,
|
|
||||||
opacityFrom: 0.45,
|
|
||||||
opacityTo: 0.05,
|
|
||||||
stops: [0, 90, 100],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
strokeDashArray: 4,
|
|
||||||
padding: { left: 12, right: 12 },
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
categories,
|
|
||||||
labels: {
|
|
||||||
style: { fontSize: '11px' },
|
|
||||||
formatter: (value) => englishToPersian(value),
|
|
||||||
},
|
|
||||||
axisBorder: { show: true },
|
|
||||||
axisTicks: { show: true },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: {
|
|
||||||
style: { fontSize: '11px' },
|
|
||||||
formatter: (value) => formatNumber(value),
|
|
||||||
minWidth: 70,
|
|
||||||
align: 'right',
|
|
||||||
offsetX: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
y: {
|
|
||||||
formatter: (value) => formatNumber(value),
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
formatter: (value) => englishToPersian(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
|
||||||
{title && (
|
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
<ReactApexChart options={options} series={series} type="area" height={250} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactApexChart from 'react-apexcharts';
|
|
||||||
import type { ApexOptions } from 'apexcharts';
|
|
||||||
import { CardTitle } from '../ui/Typography';
|
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
interface ApexBarChartCardProps {
|
|
||||||
data: { name: string; value: number }[];
|
|
||||||
title?: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value: number | string) => {
|
|
||||||
const formatted = formatWithThousands(value);
|
|
||||||
return englishToPersian(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ApexBarChartCard = ({ data, title, color = '#3b82f6' }: ApexBarChartCardProps) => {
|
|
||||||
const categories = data.map((item) => item.name);
|
|
||||||
const series = [
|
|
||||||
{
|
|
||||||
name: title || '',
|
|
||||||
data: data.map((item) => item.value),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const options: ApexOptions = {
|
|
||||||
chart: {
|
|
||||||
type: 'bar',
|
|
||||||
height: 250,
|
|
||||||
toolbar: { show: false },
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
},
|
|
||||||
colors: [color],
|
|
||||||
plotOptions: {
|
|
||||||
bar: {
|
|
||||||
borderRadius: 6,
|
|
||||||
columnWidth: '40%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
grid: {
|
|
||||||
strokeDashArray: 4,
|
|
||||||
padding: { left: 12, right: 12 },
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
categories,
|
|
||||||
labels: {
|
|
||||||
style: { fontSize: '11px' },
|
|
||||||
formatter: (value) => englishToPersian(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: {
|
|
||||||
style: { fontSize: '11px' },
|
|
||||||
formatter: (value) => formatNumber(value),
|
|
||||||
minWidth: 70,
|
|
||||||
align: 'right',
|
|
||||||
offsetX: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
y: {
|
|
||||||
formatter: (value) => formatNumber(value),
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
formatter: (value) => englishToPersian(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
|
||||||
{title && (
|
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
<ReactApexChart options={options} series={series} type="bar" height={250} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
||||||
import { CardTitle } from '../ui/Typography';
|
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
interface AreaChartCardProps {
|
|
||||||
data: any[];
|
|
||||||
title?: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value: number | string) => {
|
|
||||||
const formatted = formatWithThousands(value);
|
|
||||||
return englishToPersian(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AreaChartCard = ({ data, title, color = '#3b82f6' }: AreaChartCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
|
||||||
{title && (
|
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
<div className="w-full">
|
|
||||||
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
|
||||||
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`areaFill-${color}`} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
|
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
className="text-gray-600 dark:text-gray-400"
|
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
|
||||||
tickFormatter={(value) => englishToPersian(value)}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
minTickGap={16}
|
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
className="text-gray-600 dark:text-gray-400"
|
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
|
||||||
tickFormatter={(value) => formatNumber(value)}
|
|
||||||
width={72}
|
|
||||||
tickMargin={8}
|
|
||||||
tickCount={4}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'var(--toast-bg)',
|
|
||||||
color: 'var(--toast-color)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
formatter={(value: any) => formatNumber(value)}
|
|
||||||
labelFormatter={(label: any) => englishToPersian(label)}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={3}
|
|
||||||
fill={`url(#areaFill-${color})`}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 4 }}
|
|
||||||
/>
|
|
||||||
</RechartsAreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,71 +1,44 @@
|
||||||
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
import { ChartData } from '../../types';
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
const formatNumber = (value: number | string) => {
|
|
||||||
const formatted = formatWithThousands(value);
|
|
||||||
return englishToPersian(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
interface BarChartProps {
|
interface BarChartProps {
|
||||||
data: any[];
|
data: ChartData[];
|
||||||
title?: string;
|
title?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
<div className="card p-6">
|
||||||
{title && (
|
{title && (
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
<RechartsBarChart data={data}>
|
||||||
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||||
<defs>
|
|
||||||
<linearGradient id="barFill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.9} />
|
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0.4} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => englishToPersian(value)}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => formatNumber(value)}
|
|
||||||
width={72}
|
|
||||||
tickMargin={8}
|
|
||||||
tickCount={4}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'var(--toast-bg)',
|
backgroundColor: 'var(--tooltip-bg)',
|
||||||
color: 'var(--toast-color)',
|
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '12px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
}}
|
||||||
formatter={(value: any) => formatNumber(value)}
|
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||||
labelFormatter={(label: any) => englishToPersian(label)}
|
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" fill="url(#barFill)" radius={[8, 8, 0, 0]} barSize={28} />
|
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||||
</RechartsBarChart>
|
</RechartsBarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,72 +1,44 @@
|
||||||
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
import { ChartData } from '../../types';
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
const formatNumber = (value: number | string) => {
|
|
||||||
const formatted = formatWithThousands(value);
|
|
||||||
return englishToPersian(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
interface LineChartProps {
|
interface LineChartProps {
|
||||||
data: any[];
|
data: ChartData[];
|
||||||
title?: string;
|
title?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
|
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
<div className="card p-6">
|
||||||
{title && (
|
{title && (
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
<RechartsLineChart data={data}>
|
||||||
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||||
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => englishToPersian(value)}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
height={40}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => formatNumber(value)}
|
|
||||||
width={72}
|
|
||||||
tickMargin={8}
|
|
||||||
tickCount={4}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'var(--toast-bg)',
|
backgroundColor: 'var(--tooltip-bg)',
|
||||||
color: 'var(--toast-color)',
|
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '12px',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
}}
|
||||||
formatter={(value: any) => formatNumber(value)}
|
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||||
labelFormatter={(label: any) => englishToPersian(label)}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={3}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 5 }}
|
|
||||||
/>
|
/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} />
|
||||||
</RechartsLineChart>
|
</RechartsLineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
import { ChartData } from '../../types';
|
||||||
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
|
||||||
|
|
||||||
interface PieChartProps {
|
interface PieChartProps {
|
||||||
data: any[];
|
data: ChartData[];
|
||||||
title?: string;
|
title?: string;
|
||||||
colors?: string[];
|
colors?: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -11,81 +10,40 @@ interface PieChartProps {
|
||||||
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
|
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
|
||||||
// Custom legend component for left side
|
|
||||||
const CustomLegend = (props: any) => {
|
|
||||||
const { payload } = props;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="card p-6">
|
||||||
{payload.map((entry: any, index: number) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0 border border-white dark:border-gray-800"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
||||||
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{englishToPersian(Math.round(entry.payload.value))}%</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
|
||||||
{title && (
|
{title && (
|
||||||
<CardTitle className="mb-3 sm:mb-4 text-center">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="w-full flex items-center gap-4">
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
{/* Legend on the left */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<CustomLegend payload={data.map((item, index) => ({
|
|
||||||
value: item.name,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
payload: item
|
|
||||||
}))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart on the right */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<ResponsiveContainer width="100%" height={280} minHeight={220}>
|
|
||||||
<RechartsPieChart>
|
<RechartsPieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={false}
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
outerRadius="75%"
|
outerRadius={80}
|
||||||
innerRadius="35%"
|
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke="#fff"
|
|
||||||
strokeWidth={3}
|
|
||||||
>
|
>
|
||||||
{data.map((_, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'var(--tooltip-bg)',
|
||||||
color: '#1f2937',
|
border: 'none',
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
}}
|
||||||
formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]}
|
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||||
/>
|
/>
|
||||||
</RechartsPieChart>
|
</RechartsPieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Eye, Edit3, Trash2, LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ActionButtonsProps {
|
|
||||||
onView?: () => void;
|
|
||||||
onEdit?: () => void;
|
|
||||||
onDelete?: () => void;
|
|
||||||
viewTitle?: string;
|
|
||||||
editTitle?: string;
|
|
||||||
deleteTitle?: string;
|
|
||||||
className?: string;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
showLabels?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
|
||||||
switch (size) {
|
|
||||||
case 'sm':
|
|
||||||
return 'h-3 w-3';
|
|
||||||
case 'md':
|
|
||||||
return 'h-4 w-4';
|
|
||||||
case 'lg':
|
|
||||||
return 'h-5 w-5';
|
|
||||||
default:
|
|
||||||
return 'h-4 w-4';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTextSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
|
||||||
switch (size) {
|
|
||||||
case 'sm':
|
|
||||||
return 'text-xs';
|
|
||||||
case 'md':
|
|
||||||
return 'text-xs';
|
|
||||||
case 'lg':
|
|
||||||
return 'text-sm';
|
|
||||||
default:
|
|
||||||
return 'text-xs';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
|
||||||
onView,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
viewTitle = 'مشاهده',
|
|
||||||
editTitle = 'ویرایش',
|
|
||||||
deleteTitle = 'حذف',
|
|
||||||
className = '',
|
|
||||||
size = 'md',
|
|
||||||
showLabels = false,
|
|
||||||
}) => {
|
|
||||||
const iconSize = getSizeClasses(size);
|
|
||||||
const textSize = getTextSizeClasses(size);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-2 ${className}`}>
|
|
||||||
{onView && (
|
|
||||||
<button
|
|
||||||
onClick={onView}
|
|
||||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
|
|
||||||
title={viewTitle}
|
|
||||||
>
|
|
||||||
<Eye className={iconSize} />
|
|
||||||
{showLabels && <span className={textSize}>{viewTitle}</span>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 flex items-center gap-1"
|
|
||||||
title={editTitle}
|
|
||||||
>
|
|
||||||
<Edit3 className={iconSize} />
|
|
||||||
{showLabels && <span className={textSize}>{editTitle}</span>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1"
|
|
||||||
title={deleteTitle}
|
|
||||||
>
|
|
||||||
<Trash2 className={iconSize} />
|
|
||||||
{showLabels && <span className={textSize}>{deleteTitle}</span>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Modal } from '../ui/Modal';
|
|
||||||
import { Button } from '../ui/Button';
|
|
||||||
|
|
||||||
interface DeleteConfirmModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
warningMessage?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
itemName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title = 'حذف',
|
|
||||||
message,
|
|
||||||
warningMessage,
|
|
||||||
isLoading = false,
|
|
||||||
itemName,
|
|
||||||
}) => {
|
|
||||||
const defaultMessage = itemName
|
|
||||||
? `آیا از حذف "${itemName}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`
|
|
||||||
: 'آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
{message || defaultMessage}
|
|
||||||
</p>
|
|
||||||
{warningMessage && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{warningMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={onConfirm}
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
|
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { Button } from '../ui/Button';
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
icon?: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
actionLabel?: ReactNode;
|
|
||||||
onAction?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`text-center py-12 ${className}`}>
|
|
||||||
{Icon && (
|
|
||||||
<Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
|
||||||
)}
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{actionLabel && onAction && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<Button onClick={onAction} className="flex items-center gap-2 mx-auto">
|
|
||||||
{actionLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(_: Error): State {
|
static getDerivedStateFromError(error: Error): State {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface FiltersSectionProps {
|
|
||||||
children: ReactNode;
|
|
||||||
isLoading?: boolean;
|
|
||||||
columns?: 1 | 2 | 3 | 4;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FiltersSection: React.FC<FiltersSectionProps> = ({
|
|
||||||
children,
|
|
||||||
isLoading = false,
|
|
||||||
columns = 4,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
const gridCols = {
|
|
||||||
1: 'grid-cols-1',
|
|
||||||
2: 'grid-cols-1 md:grid-cols-2',
|
|
||||||
3: 'grid-cols-1 md:grid-cols-3',
|
|
||||||
4: 'grid-cols-1 md:grid-cols-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 ${className}`}>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className={`grid ${gridCols[columns]} gap-4 animate-pulse`}>
|
|
||||||
{[...Array(columns)].map((_, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
|
||||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`grid ${gridCols[columns]} gap-4`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ReportSkeletonProps {
|
|
||||||
summaryCardCount?: number;
|
|
||||||
tableColumnCount?: number;
|
|
||||||
tableRowCount?: number;
|
|
||||||
showMethodSummaries?: boolean;
|
|
||||||
showChart?: boolean;
|
|
||||||
showPaymentTypeCards?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReportSkeleton: React.FC<ReportSkeletonProps> = ({
|
|
||||||
summaryCardCount = 4,
|
|
||||||
tableColumnCount = 7,
|
|
||||||
tableRowCount = 5,
|
|
||||||
showMethodSummaries = false,
|
|
||||||
showChart = false,
|
|
||||||
showPaymentTypeCards = false,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Summary Cards Skeleton */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
{[...Array(summaryCardCount)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Method Summaries Skeleton */}
|
|
||||||
{showMethodSummaries && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{[...Array(6)].map((_, j) => (
|
|
||||||
<div key={j} className="flex justify-between">
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pie Chart and Total Amount Skeleton */}
|
|
||||||
{showChart && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
||||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
|
|
||||||
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
|
|
||||||
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
|
|
||||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment Type Cards Skeleton */}
|
|
||||||
{showPaymentTypeCards && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
|
|
||||||
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{[...Array(5)].map((_, j) => (
|
|
||||||
<div key={j} className="flex justify-between">
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table Skeleton */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
{[...Array(tableColumnCount)].map((_, i) => (
|
|
||||||
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{[...Array(tableRowCount)].map((_, i) => (
|
|
||||||
<tr key={i} className="animate-pulse">
|
|
||||||
{[...Array(tableColumnCount)].map((_, j) => (
|
|
||||||
<td key={j} className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface TableSkeletonProps {
|
|
||||||
columns?: number;
|
|
||||||
rows?: number;
|
|
||||||
showMobileCards?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
|
||||||
columns = 5,
|
|
||||||
rows = 5,
|
|
||||||
showMobileCards = true,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}>
|
|
||||||
{/* Desktop Table Skeleton */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
{[...Array(columns)].map((_, i) => (
|
|
||||||
<th
|
|
||||||
key={i}
|
|
||||||
className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-24 animate-pulse"></div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{[...Array(rows)].map((_, rowIndex) => (
|
|
||||||
<tr key={rowIndex}>
|
|
||||||
{[...Array(columns)].map((_, colIndex) => (
|
|
||||||
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{colIndex === columns - 1 ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
|
||||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse w-32"></div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Cards Skeleton */}
|
|
||||||
{showMobileCards && (
|
|
||||||
<div className="md:hidden p-4 space-y-4">
|
|
||||||
{[...Array(Math.min(rows, 3))].map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse"
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
|
|
||||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
|
|
||||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
|
||||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
import { StatValue, StatLabel } from '../ui/Typography';
|
|
||||||
|
|
||||||
interface StatsCardProps {
|
interface StatsCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -28,31 +27,31 @@ export const StatsCard = ({
|
||||||
const isNegative = change && change < 0;
|
const isNegative = change && change < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in">
|
<div className="card p-6 animate-fade-in">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}>
|
<div className={`p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
||||||
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
<Icon className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-3 sm:mr-5 w-0 flex-1 min-w-0">
|
<div className="mr-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<StatLabel className="truncate">
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||||
{title}
|
{title}
|
||||||
</StatLabel>
|
</dt>
|
||||||
<dd className="flex items-baseline">
|
<dd className="flex items-baseline">
|
||||||
<StatValue className="truncate">
|
<div className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
</StatValue>
|
</div>
|
||||||
{change !== undefined && (
|
{change !== undefined && (
|
||||||
<div className={`mr-1 sm:mr-2 flex items-baseline text-xs sm:text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
|
<div className={`mr-2 flex items-baseline text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
{isPositive && <TrendingUp className="h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
|
{isPositive && <TrendingUp className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
|
||||||
{isNegative && <TrendingDown className="h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
|
{isNegative && <TrendingDown className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{isPositive ? 'افزایش' : 'کاهش'}
|
{isPositive ? 'افزایش' : 'کاهش'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{Math.abs(change)}%</span>
|
{Math.abs(change)}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '../ui/Button';
|
|
||||||
|
|
||||||
interface FormActionsProps {
|
|
||||||
onCancel?: () => void;
|
|
||||||
cancelLabel?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormActions: React.FC<FormActionsProps> = ({
|
|
||||||
onCancel,
|
|
||||||
cancelLabel = 'انصراف',
|
|
||||||
submitLabel = 'ذخیره',
|
|
||||||
isLoading = false,
|
|
||||||
isDisabled = false,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isDisabled || isLoading}
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
import { SectionTitle } from '../ui/Typography';
|
|
||||||
|
|
||||||
interface FormSectionProps {
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSection: React.FC<FormSectionProps> = ({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
titleClassName = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<SectionTitle className={`mb-4 ${titleClassName}`}>
|
|
||||||
{title}
|
|
||||||
</SectionTitle>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,82 +1,126 @@
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import * as yup from 'yup';
|
import { User, Phone, Mail, UserCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../ui/Button';
|
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import { UserFormData } from '../../utils/validationSchemas';
|
import { Button } from '../ui/Button';
|
||||||
|
import { userSchema, UserFormData } from '../../utils/validationSchemas';
|
||||||
const userSchema = yup.object({
|
|
||||||
name: yup.string().required('نام الزامی است'),
|
|
||||||
email: yup.string().email('ایمیل معتبر نیست').required('ایمیل الزامی است'),
|
|
||||||
phone: yup.string().required('شماره تلفن الزامی است'),
|
|
||||||
role: yup.string().required('نقش الزامی است'),
|
|
||||||
password: yup.string().notRequired(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
|
initialData?: Partial<UserFormData>;
|
||||||
onSubmit: (data: UserFormData) => void;
|
onSubmit: (data: UserFormData) => void;
|
||||||
defaultValues?: Partial<UserFormData>;
|
onCancel: () => void;
|
||||||
initialData?: any;
|
|
||||||
onCancel?: () => void;
|
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserForm = ({ onSubmit, defaultValues, initialData, onCancel, loading, isEdit, isLoading }: UserFormProps) => {
|
export const UserForm = ({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
isEdit = false
|
||||||
|
}: UserFormProps) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isValid }
|
formState: { errors, isValid },
|
||||||
} = useForm({
|
} = useForm<UserFormData>({
|
||||||
resolver: yupResolver(userSchema),
|
resolver: yupResolver(userSchema),
|
||||||
defaultValues: defaultValues || initialData,
|
mode: 'onChange',
|
||||||
mode: 'onChange'
|
defaultValues: initialData,
|
||||||
}) as any;
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="card p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
اطلاعات کاربر
|
{isEdit ? 'ویرایش کاربر' : 'افزودن کاربر جدید'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
لطفا اطلاعات کاربر را کامل کنید
|
اطلاعات کاربر را وارد کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit as any)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Input
|
<Input
|
||||||
label="نام"
|
label="نام و نام خانوادگی"
|
||||||
{...register('name')}
|
placeholder="علی احمدی"
|
||||||
|
icon={User}
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
placeholder="نام کاربر"
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="ایمیل"
|
label="ایمیل"
|
||||||
type="email"
|
type="email"
|
||||||
{...register('email')}
|
placeholder="ali@example.com"
|
||||||
|
icon={Mail}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
placeholder="example@email.com"
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="تلفن"
|
label="شماره تلفن"
|
||||||
type="tel"
|
type="tel"
|
||||||
{...register('phone')}
|
placeholder="09123456789"
|
||||||
|
icon={Phone}
|
||||||
error={errors.phone?.message}
|
error={errors.phone?.message}
|
||||||
placeholder="09xxxxxxxxx"
|
{...register('phone')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="space-y-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
نقش
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<UserCircle className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className={`input pr-10 ${errors.role ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
|
||||||
|
}`}
|
||||||
|
{...register('role')}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب کنید</option>
|
||||||
|
<option value="کاربر">کاربر</option>
|
||||||
|
<option value="مدیر">مدیر</option>
|
||||||
|
<option value="ادمین">ادمین</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{errors.role && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.role.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<Input
|
||||||
|
label="رمز عبور"
|
||||||
|
type="password"
|
||||||
|
placeholder="حداقل ۶ کاراکتر"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid || isLoading}
|
loading={loading}
|
||||||
className="w-full"
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
{isLoading ? 'در حال ذخیره...' : 'ذخیره'}
|
{isEdit ? 'ویرایش' : 'افزودن'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { SectionTitle } from '../ui/Typography';
|
import { Button } from '../ui/Button';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
|
|
@ -14,19 +14,21 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
|
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<div className="flex items-center space-x-4 space-x-reverse">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<SectionTitle>خوش آمدید</SectionTitle>
|
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
خوش آمدید
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 space-x-reverse">
|
<div className="flex items-center space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
|
@ -46,15 +48,15 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
className="flex items-center space-x-2 space-x-reverse p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||||
<span className="text-white text-sm font-medium">
|
<span className="text-white text-sm font-medium">
|
||||||
{user?.first_name?.charAt(0) || 'A'}
|
{user?.name?.charAt(0) || 'A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:block">
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden md:block">
|
||||||
{user?.first_name} {user?.last_name}
|
{user?.name || 'کاربر'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -63,10 +65,10 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{user?.first_name} {user?.last_name}
|
{user?.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{user?.username}
|
{user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="w-full text-right px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">
|
<button className="w-full text-right px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,23 @@
|
||||||
import { Suspense, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
|
||||||
const ContentSkeleton = () => (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="h-10 w-1/3 rounded-lg bg-gray-200 dark:bg-gray-800 animate-pulse" />
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[...Array(3)].map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="h-36 rounded-lg bg-white dark:bg-gray-800 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="h-96 rounded-lg bg-white dark:bg-gray-800 shadow-sm">
|
|
||||||
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Layout = () => {
|
export const Layout = () => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
|
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="min-h-full py-6 px-4 sm:px-6 lg:px-8">
|
|
||||||
<Suspense fallback={<ContentSkeleton />}>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
actions?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
icon: Icon,
|
|
||||||
actions,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 ${className}`}>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
||||||
{Icon && <Icon className="h-6 w-6" />}
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{actions && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,186 +1,62 @@
|
||||||
import React from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Home,
|
LayoutDashboard,
|
||||||
Settings,
|
|
||||||
Shield,
|
|
||||||
UserCog,
|
|
||||||
Key,
|
|
||||||
LogOut,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronLeft,
|
|
||||||
Package,
|
|
||||||
FolderOpen,
|
|
||||||
Sliders,
|
|
||||||
BadgePercent,
|
|
||||||
ShoppingCart,
|
|
||||||
Users,
|
Users,
|
||||||
Truck,
|
ShoppingBag,
|
||||||
X,
|
ShoppingCart,
|
||||||
MessageSquare,
|
|
||||||
CreditCard,
|
|
||||||
Wallet,
|
|
||||||
BarChart3,
|
|
||||||
FileText,
|
FileText,
|
||||||
TrendingUp
|
Bell,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ChevronDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||||
import { SectionTitle, SmallText } from '../ui/Typography';
|
import { MenuItem } from '../../types';
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
title: string;
|
|
||||||
icon: any;
|
|
||||||
path?: string;
|
|
||||||
permission?: number;
|
|
||||||
children?: MenuItem[];
|
|
||||||
exact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: 'داشبورد',
|
id: 'dashboard',
|
||||||
icon: Home,
|
label: 'داشبورد',
|
||||||
|
icon: LayoutDashboard,
|
||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'سفارشات',
|
id: 'users',
|
||||||
|
label: 'کاربران',
|
||||||
|
icon: Users,
|
||||||
|
path: '/users',
|
||||||
|
permission: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'products',
|
||||||
|
label: 'محصولات',
|
||||||
|
icon: ShoppingBag,
|
||||||
|
path: '/products',
|
||||||
|
permission: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orders',
|
||||||
|
label: 'سفارشات',
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
path: '/orders',
|
path: '/orders',
|
||||||
|
permission: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت کاربران',
|
id: 'reports',
|
||||||
icon: Users,
|
label: 'گزارشها',
|
||||||
path: '/users-admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'کدهای تخفیف',
|
|
||||||
icon: BadgePercent,
|
|
||||||
path: '/discount-codes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'تیکتها',
|
|
||||||
icon: MessageSquare,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: 'لیست تیکتها',
|
|
||||||
icon: MessageSquare,
|
|
||||||
path: '/tickets',
|
|
||||||
exact: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'تنظیمات تیکت',
|
|
||||||
icon: Sliders,
|
|
||||||
path: '/tickets/config',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'پیامهای تماس با ما',
|
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: '/contact-us',
|
path: '/reports',
|
||||||
|
permission: 25,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت محصولات',
|
id: 'notifications',
|
||||||
icon: Package,
|
label: 'اعلانات',
|
||||||
children: [
|
icon: Bell,
|
||||||
{
|
path: '/notifications',
|
||||||
title: 'محصولات',
|
permission: 30,
|
||||||
icon: Package,
|
|
||||||
path: '/products',
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'دستهبندیها',
|
|
||||||
icon: FolderOpen,
|
|
||||||
path: '/categories',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'گزینههای محصول',
|
|
||||||
icon: Sliders,
|
|
||||||
path: '/product-options',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'نظرات محصولات',
|
|
||||||
icon: MessageSquare,
|
|
||||||
path: '/products/comments',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'گزارشها',
|
|
||||||
icon: BarChart3,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: 'گزارش کدهای تخفیف',
|
|
||||||
icon: BadgePercent,
|
|
||||||
path: '/reports/discount-usage',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'گزارش کاربر و کد تخفیف',
|
|
||||||
icon: Users,
|
|
||||||
path: '/reports/customer-discount-usage',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'گزارش روشهای پرداخت',
|
|
||||||
icon: CreditCard,
|
|
||||||
path: '/reports/payment-methods',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'گزارش ارسالها',
|
|
||||||
icon: Truck,
|
|
||||||
path: '/reports/shipments-by-method',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'مدیریت سیستم',
|
|
||||||
icon: Settings,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
title: 'نقشها',
|
|
||||||
icon: Shield,
|
|
||||||
path: '/roles',
|
|
||||||
permission: 22,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'کاربران ادمین',
|
|
||||||
icon: UserCog,
|
|
||||||
path: '/admin-users',
|
|
||||||
permission: 22,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'دسترسیها',
|
|
||||||
icon: Key,
|
|
||||||
path: '/permissions',
|
|
||||||
permission: 22,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'اسلایدر لندینگ',
|
|
||||||
icon: Sliders,
|
|
||||||
path: '/landing-hero',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'روشهای ارسال',
|
|
||||||
icon: Truck,
|
|
||||||
path: '/shipping-methods',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'درگاههای پرداخت',
|
|
||||||
icon: CreditCard,
|
|
||||||
path: '/payment-ipg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'پرداخت کارت به کارت',
|
|
||||||
icon: CreditCard,
|
|
||||||
path: '/payment-card',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'مدیریت کیف پول',
|
|
||||||
icon: Wallet,
|
|
||||||
path: '/wallet',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|
@ -189,129 +65,98 @@ interface SidebarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const { user, logout } = useAuth();
|
const { user } = useAuth();
|
||||||
const location = useLocation();
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||||
const [expandedItems, setExpandedItems] = React.useState<string[]>(() => {
|
|
||||||
// Load from localStorage on mount
|
|
||||||
const saved = localStorage.getItem('sidebar_expanded_items');
|
|
||||||
return saved ? JSON.parse(saved) : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-expand menu items based on current route
|
const toggleExpanded = (itemId: string) => {
|
||||||
React.useEffect(() => {
|
setExpandedItems(prev =>
|
||||||
const currentPath = location.pathname;
|
prev.includes(itemId)
|
||||||
|
? prev.filter(id => id !== itemId)
|
||||||
setExpandedItems(prev => {
|
: [...prev, itemId]
|
||||||
const itemsToExpand: string[] = [];
|
);
|
||||||
|
|
||||||
menuItems.forEach(item => {
|
|
||||||
if (item.children) {
|
|
||||||
// Check if any child matches current path
|
|
||||||
const hasActiveChild = item.children.some(child => {
|
|
||||||
if (child.path) {
|
|
||||||
if (child.exact) {
|
|
||||||
return currentPath === child.path;
|
|
||||||
}
|
|
||||||
return currentPath.startsWith(child.path);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasActiveChild && !prev.includes(item.title)) {
|
|
||||||
itemsToExpand.push(item.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (itemsToExpand.length > 0) {
|
|
||||||
return [...prev, ...itemsToExpand];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Save to localStorage whenever expandedItems changes
|
|
||||||
localStorage.setItem('sidebar_expanded_items', JSON.stringify(expandedItems));
|
|
||||||
}, [expandedItems]);
|
|
||||||
|
|
||||||
const toggleExpanded = (title: string) => {
|
|
||||||
setExpandedItems(prev => {
|
|
||||||
const newItems = prev.includes(title)
|
|
||||||
? prev.filter(item => item !== title)
|
|
||||||
: [...prev, title];
|
|
||||||
return newItems;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
const renderMenuItem = (item: MenuItem) => {
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.includes(item.title);
|
const isExpanded = expandedItems.includes(item.id);
|
||||||
const paddingLeft = depth * 16;
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
return (
|
|
||||||
<div key={item.title} className="space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExpanded(item.title)}
|
|
||||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200
|
|
||||||
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-sm`}
|
|
||||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
|
||||||
>
|
|
||||||
<item.icon className="ml-3 h-5 w-5" />
|
|
||||||
<span className="flex-1 text-right">{item.title}</span>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{isExpanded && item.children && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{item.children.map(child => renderMenuItem(child, depth + 1))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuContent = (
|
const menuContent = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-4 py-3 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${hasChildren ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
onClick={hasChildren ? () => toggleExpanded(item.id) : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<item.icon className="h-5 w-5 ml-3" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div className="mr-8 mt-1 space-y-1">
|
||||||
|
{item.children?.map(child => (
|
||||||
|
<div key={child.id}>
|
||||||
|
{child.permission ? (
|
||||||
|
<PermissionWrapper permission={child.permission}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.path!}
|
to={child.path}
|
||||||
end={item.exact}
|
|
||||||
onClick={() => {
|
|
||||||
// Close mobile menu when clicking a link
|
|
||||||
if (window.innerWidth < 1024) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive
|
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
|
||||||
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400 shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white hover:shadow-sm'
|
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<item.icon className="ml-3 h-5 w-5" />
|
{child.label}
|
||||||
{item.title}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</PermissionWrapper>
|
||||||
|
) : (
|
||||||
|
<NavLink
|
||||||
|
to={child.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ${isActive ? 'bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (item.permission) {
|
if (!hasChildren) {
|
||||||
return (
|
return (
|
||||||
<PermissionWrapper key={item.title} permission={item.permission}>
|
<NavLink
|
||||||
{menuContent}
|
to={item.path}
|
||||||
</PermissionWrapper>
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-4 py-3 rounded-lg transition-colors ${isActive
|
||||||
|
? 'text-primary-700 dark:text-primary-300 bg-primary-100 dark:bg-primary-900'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 ml-3" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div key={item.title}>{menuContent}</div>;
|
return <div>{menuContent}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||||
|
|
@ -319,62 +164,58 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className={`
|
<div className={`
|
||||||
fixed lg:static inset-y-0 right-0 z-50
|
fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-800 shadow-lg z-50 transform transition-transform duration-300 ease-in-out
|
||||||
w-64 transform transition-transform duration-300 ease-in-out
|
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||||
lg:translate-x-0 lg:block
|
lg:relative lg:translate-x-0
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
|
|
||||||
flex flex-col h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none
|
|
||||||
`}>
|
`}>
|
||||||
{/* Mobile close button */}
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
<div className="flex items-center">
|
||||||
<SectionTitle>
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
پنل مدیریت
|
<LayoutDashboard className="h-5 w-5 text-white" />
|
||||||
</SectionTitle>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="mr-3 text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{/* Logo - desktop only */}
|
|
||||||
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
||||||
<SectionTitle>
|
|
||||||
پنل مدیریت
|
پنل مدیریت
|
||||||
</SectionTitle>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
|
|
||||||
{menuItems.map(item => renderMenuItem(item))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
|
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
|
||||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
|
||||||
<span className="text-sm font-medium text-white">
|
|
||||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<SmallText>
|
|
||||||
{user?.first_name} {user?.last_name}
|
|
||||||
</SmallText>
|
|
||||||
<SmallText>
|
|
||||||
{user?.username}
|
|
||||||
</SmallText>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
|
||||||
>
|
>
|
||||||
<LogOut className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center mb-6 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{user?.name?.charAt(0) || 'A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{user?.name || 'کاربر'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{user?.role || 'مدیر'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.permission ? (
|
||||||
|
<PermissionWrapper permission={item.permission}>
|
||||||
|
{renderMenuItem(item)}
|
||||||
|
</PermissionWrapper>
|
||||||
|
) : (
|
||||||
|
renderMenuItem(item)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { MouseEvent, ButtonHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick'> {
|
interface ButtonProps {
|
||||||
children: any;
|
children: any;
|
||||||
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
variant?: 'primary' | 'secondary' | 'danger' | 'success';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onClick?: (e?: MouseEvent<HTMLButtonElement>) => void;
|
onClick?: () => void;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: 'button' | 'submit' | 'reset';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -21,9 +20,8 @@ export const Button = ({
|
||||||
onClick,
|
onClick,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
className = '',
|
className = '',
|
||||||
...rest
|
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const baseClasses = 'inline-flex items-center justify-center rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-sm hover:shadow-md';
|
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
||||||
|
|
@ -54,7 +52,6 @@ export const Button = ({
|
||||||
disabledClasses,
|
disabledClasses,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...rest}
|
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,351 +0,0 @@
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
||||||
import { Upload, X, Image, File, AlertCircle, CheckCircle } from 'lucide-react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
export interface UploadedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
type: string;
|
|
||||||
url?: string;
|
|
||||||
preview?: string;
|
|
||||||
progress: number;
|
|
||||||
status: 'uploading' | 'completed' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileUploaderProps {
|
|
||||||
onUpload: (file: File) => Promise<{ id: string; url: string; mimeType?: string }>;
|
|
||||||
onRemove?: (fileId: string) => void;
|
|
||||||
acceptedTypes?: string[];
|
|
||||||
maxFileSize?: number;
|
|
||||||
maxFiles?: number;
|
|
||||||
label?: string;
|
|
||||||
description?: string;
|
|
||||||
error?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
mode?: 'single' | 'multi';
|
|
||||||
onUploadStart?: () => void;
|
|
||||||
onUploadComplete?: () => void;
|
|
||||||
initialFiles?: Array<Partial<UploadedFile> & { id: string; url?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileUploader: React.FC<FileUploaderProps> = ({
|
|
||||||
onUpload,
|
|
||||||
onRemove,
|
|
||||||
acceptedTypes = ['image/*', 'video/*'],
|
|
||||||
maxFileSize = 10 * 1024 * 1024,
|
|
||||||
maxFiles = 10,
|
|
||||||
label = "فایلها",
|
|
||||||
description = "تصاویر و ویدیوها را اینجا بکشید یا کلیک کنید",
|
|
||||||
error,
|
|
||||||
disabled = false,
|
|
||||||
className = "",
|
|
||||||
mode = 'multi',
|
|
||||||
onUploadStart,
|
|
||||||
onUploadComplete,
|
|
||||||
initialFiles = [],
|
|
||||||
}) => {
|
|
||||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const isImage = (type: string) => type.startsWith('image/');
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialFiles && initialFiles.length > 0) {
|
|
||||||
const normalized: UploadedFile[] = initialFiles.map((f) => ({
|
|
||||||
id: f.id,
|
|
||||||
name: f.name || (f.url ? f.url.split('/').pop() || 'file' : 'file'),
|
|
||||||
size: typeof f.size === 'number' ? f.size : 0,
|
|
||||||
type: f.type || 'image/*',
|
|
||||||
url: f.url,
|
|
||||||
preview: f.preview,
|
|
||||||
progress: 100,
|
|
||||||
status: 'completed',
|
|
||||||
}));
|
|
||||||
setFiles(mode === 'single' ? [normalized[0]] : normalized);
|
|
||||||
}
|
|
||||||
}, [initialFiles, mode]);
|
|
||||||
|
|
||||||
const validateFile = (file: File) => {
|
|
||||||
if (maxFileSize && file.size > maxFileSize) {
|
|
||||||
return `حجم فایل نباید بیشتر از ${formatFileSize(maxFileSize)} باشد`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acceptedTypes.length > 0) {
|
|
||||||
const isAccepted = acceptedTypes.some(type => {
|
|
||||||
if (type === 'image/*') return file.type.startsWith('image/');
|
|
||||||
if (type === 'video/*') return file.type.startsWith('video/');
|
|
||||||
return file.type === type;
|
|
||||||
});
|
|
||||||
if (!isAccepted) {
|
|
||||||
return 'نوع فایل پشتیبانی نمیشود';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxFiles && files.length >= maxFiles) {
|
|
||||||
return `حداکثر ${maxFiles} فایل مجاز است`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFilePreview = (file: File) => {
|
|
||||||
return new Promise<string>((resolve) => {
|
|
||||||
if (isImage(file.type)) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => resolve(e.target?.result as string);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} else {
|
|
||||||
resolve('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (file: File) => {
|
|
||||||
const validationError = validateFile(file);
|
|
||||||
if (validationError) {
|
|
||||||
const errorFile: UploadedFile = {
|
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
progress: 0,
|
|
||||||
status: 'error',
|
|
||||||
error: validationError,
|
|
||||||
};
|
|
||||||
setFiles(prev => mode === 'single' ? [errorFile] : [...prev, errorFile]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUploadStart?.();
|
|
||||||
|
|
||||||
const fileId = Math.random().toString(36).substr(2, 9);
|
|
||||||
const preview = await createFilePreview(file);
|
|
||||||
|
|
||||||
const newFile: UploadedFile = {
|
|
||||||
id: fileId,
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
preview,
|
|
||||||
progress: 0,
|
|
||||||
status: 'uploading',
|
|
||||||
};
|
|
||||||
|
|
||||||
setFiles(prev => mode === 'single' ? [newFile] : [...prev, newFile]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setFiles(prev => prev.map(f =>
|
|
||||||
f.id === fileId && f.progress < 90
|
|
||||||
? { ...f, progress: f.progress + 10 }
|
|
||||||
: f
|
|
||||||
));
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const result = await onUpload(file);
|
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
|
|
||||||
setFiles(prev => prev.map(f =>
|
|
||||||
f.id === fileId
|
|
||||||
? { ...f, progress: 100, status: 'completed', url: result.url, id: result.id }
|
|
||||||
: f
|
|
||||||
));
|
|
||||||
|
|
||||||
onUploadComplete?.();
|
|
||||||
} catch (error: any) {
|
|
||||||
setFiles(prev => prev.map(f =>
|
|
||||||
f.id === fileId
|
|
||||||
? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' }
|
|
||||||
: f
|
|
||||||
));
|
|
||||||
onUploadComplete?.();
|
|
||||||
}
|
|
||||||
}, [onUpload, maxFiles, maxFileSize, acceptedTypes, mode, onUploadStart, onUploadComplete]);
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback((selectedFiles: FileList) => {
|
|
||||||
Array.from(selectedFiles).forEach(file => {
|
|
||||||
handleFileUpload(file);
|
|
||||||
});
|
|
||||||
}, [handleFileUpload]);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files;
|
|
||||||
handleFileSelect(droppedFiles);
|
|
||||||
}, [disabled, handleFileSelect]);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!disabled) setIsDragOver(true);
|
|
||||||
}, [disabled]);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!disabled) fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (fileId: string) => {
|
|
||||||
setFiles(prev => prev.filter(f => f.id !== fileId));
|
|
||||||
onRemove?.(fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUploadedFiles = files.some(f => f.status === 'completed');
|
|
||||||
const showUploadArea = mode === 'multi' || (mode === 'single' && !hasUploadedFiles);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-4 ${className}`}>
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showUploadArea && (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
|
|
||||||
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
|
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
|
|
||||||
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}
|
|
||||||
`}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple={mode === 'multi'}
|
|
||||||
accept={acceptedTypes.join(',')}
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
فایلهای آپلود شده ({files.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{files.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{(file.preview || file.url) ? (
|
|
||||||
<img
|
|
||||||
src={(file.preview || file.url) as string}
|
|
||||||
alt={file.name}
|
|
||||||
className="w-10 h-10 object-cover rounded"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
|
||||||
{isImage(file.type) ? (
|
|
||||||
<Image className="h-5 w-5 text-gray-500" />
|
|
||||||
) : (
|
|
||||||
<File className="h-5 w-5 text-gray-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{file.status === 'uploading' && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5">
|
|
||||||
<div
|
|
||||||
className="bg-primary-600 h-1.5 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${file.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">{file.progress}%</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{file.status === 'error' && file.error && (
|
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
{file.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{file.status === 'completed' && (
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
||||||
)}
|
|
||||||
{file.status === 'error' && (
|
|
||||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e?.stopPropagation();
|
|
||||||
handleRemove(file.id);
|
|
||||||
}}
|
|
||||||
className="p-1 h-8 w-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,88 +1,54 @@
|
||||||
import React from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { Label } from './Typography';
|
|
||||||
import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
|
|
||||||
|
|
||||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
interface InputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
type?: string;
|
||||||
inputSize?: 'sm' | 'md' | 'lg';
|
placeholder?: string;
|
||||||
icon?: React.ComponentType<{ className?: string }>;
|
className?: string;
|
||||||
numeric?: boolean;
|
icon?: any;
|
||||||
thousandSeparator?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ label, error, helperText, inputSize = 'md', className, id, onChange, type, numeric, thousandSeparator, ...props }, ref) => {
|
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'px-3 py-2 text-sm',
|
|
||||||
md: 'px-3 py-3 text-base',
|
|
||||||
lg: 'px-4 py-4 text-lg'
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputClasses = clsx(
|
|
||||||
'w-full border rounded-lg transition-all duration-200 focus:outline-none focus:ring-2',
|
|
||||||
sizeClasses[inputSize],
|
|
||||||
error
|
|
||||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
|
||||||
: 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500',
|
|
||||||
'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100',
|
|
||||||
'placeholder-gray-500 dark:placeholder-gray-400',
|
|
||||||
className
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
let value = e.target.value;
|
|
||||||
if ((type === 'number' || numeric) && value) {
|
|
||||||
value = persianToEnglish(value);
|
|
||||||
}
|
|
||||||
if (thousandSeparator) {
|
|
||||||
const caret = e.target.selectionStart || 0;
|
|
||||||
const prevLength = e.target.value.length;
|
|
||||||
value = formatWithThousands(value);
|
|
||||||
e.target.value = value;
|
|
||||||
const newLength = value.length;
|
|
||||||
const delta = newLength - prevLength;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
try {
|
|
||||||
e.target.setSelectionRange(caret + delta, caret + delta);
|
|
||||||
} catch { }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
e.target.value = value;
|
|
||||||
}
|
|
||||||
onChange?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInputMode = (): "numeric" | "decimal" | undefined => {
|
|
||||||
if (numeric) {
|
|
||||||
return type === 'number' ? 'decimal' : 'numeric';
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputProps = {
|
|
||||||
ref,
|
|
||||||
id,
|
|
||||||
type: numeric || thousandSeparator ? 'text' : type,
|
|
||||||
inputMode: getInputMode(),
|
|
||||||
className: inputClasses,
|
|
||||||
onChange: handleChange,
|
|
||||||
...props
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{label && <Label htmlFor={id}>{label}</Label>}
|
{label && (
|
||||||
<input {...inputProps} />
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{helperText && !error && (
|
{label}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{Icon && (
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Icon className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={clsx(
|
||||||
|
'input',
|
||||||
|
Icon && 'pr-10',
|
||||||
|
error && 'border-red-500 dark:border-red-500 focus:ring-red-500',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import DatePicker from 'react-multi-date-picker';
|
|
||||||
import TimePicker from 'react-multi-date-picker/plugins/time_picker';
|
|
||||||
import persian from 'react-date-object/calendars/persian';
|
|
||||||
import persian_fa from 'react-date-object/locales/persian_fa';
|
|
||||||
import DateObject from 'react-date-object';
|
|
||||||
import { Label } from './Typography';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface JalaliDateTimePickerProps {
|
|
||||||
label?: string;
|
|
||||||
value?: string | null;
|
|
||||||
onChange: (value: string | undefined) => void;
|
|
||||||
error?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toIsoLike = (date?: DateObject | null): string | undefined => {
|
|
||||||
if (!date) return undefined;
|
|
||||||
try {
|
|
||||||
const g = date.convert(undefined);
|
|
||||||
const yyyy = g.year.toString().padStart(4, '0');
|
|
||||||
const mm = g.month.toString().padStart(2, '0');
|
|
||||||
const dd = g.day.toString().padStart(2, '0');
|
|
||||||
const hh = g.hour.toString().padStart(2, '0');
|
|
||||||
const mi = g.minute.toString().padStart(2, '0');
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:00Z`;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromIsoToDateObject = (value?: string | null): DateObject | undefined => {
|
|
||||||
if (!value) return undefined;
|
|
||||||
try {
|
|
||||||
const d = new Date(value);
|
|
||||||
if (isNaN(d.getTime())) return undefined;
|
|
||||||
return new DateObject(d).convert(persian, persian_fa);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ label, value, onChange, error, placeholder }) => {
|
|
||||||
const selected = fromIsoToDateObject(value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{label && <Label>{label}</Label>}
|
|
||||||
<div className="relative">
|
|
||||||
<DatePicker
|
|
||||||
value={selected}
|
|
||||||
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
|
|
||||||
format="YYYY/MM/DD HH:mm"
|
|
||||||
calendar={persian}
|
|
||||||
locale={persian_fa}
|
|
||||||
calendarPosition="bottom-center"
|
|
||||||
disableDayPicker={false}
|
|
||||||
inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
|
|
||||||
containerClassName="w-full"
|
|
||||||
placeholder={placeholder || 'تاریخ و ساعت'}
|
|
||||||
editable={false}
|
|
||||||
plugins={[<TimePicker key="time" position="bottom" />]}
|
|
||||||
disableMonthPicker={false}
|
|
||||||
disableYearPicker={false}
|
|
||||||
showOtherDays
|
|
||||||
/>
|
|
||||||
{value && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(undefined);
|
|
||||||
}}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
title="پاک کردن"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default JalaliDateTimePicker;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { SectionSubtitle } from './Typography';
|
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title?: string;
|
||||||
children: React.ReactNode;
|
children: any;
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
showCloseButton?: boolean;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = ({
|
export const Modal = ({
|
||||||
|
|
@ -17,9 +14,7 @@ export const Modal = ({
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
size = 'md',
|
size = 'md'
|
||||||
showCloseButton = true,
|
|
||||||
actions
|
|
||||||
}: ModalProps) => {
|
}: ModalProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
|
@ -45,7 +40,7 @@ export const Modal = ({
|
||||||
sm: 'max-w-md',
|
sm: 'max-w-md',
|
||||||
md: 'max-w-lg',
|
md: 'max-w-lg',
|
||||||
lg: 'max-w-2xl',
|
lg: 'max-w-2xl',
|
||||||
xl: 'max-w-4xl'
|
xl: 'max-w-4xl',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -57,31 +52,26 @@ export const Modal = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`
|
<div className={`
|
||||||
relative w-full ${sizeClasses[size]}
|
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
|
||||||
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
|
${sizeClasses[size]} transform transition-all
|
||||||
transform transition-all border border-gray-200 dark:border-gray-700
|
|
||||||
`}>
|
`}>
|
||||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
{title && (
|
||||||
<SectionSubtitle>{title}</SectionSubtitle>
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
{showCloseButton && (
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actions && (
|
|
||||||
<div className="flex flex-col space-y-2 sm:flex-row sm:justify-end sm:space-y-0 sm:space-x-3 sm:space-x-reverse p-4 sm:p-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { ChevronDown, X } from 'lucide-react';
|
|
||||||
|
|
||||||
export interface Option {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiSelectAutocompleteProps {
|
|
||||||
options: Option[];
|
|
||||||
selectedValues: number[];
|
|
||||||
onChange: (values: number[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onSearchChange?: (query: string) => void;
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
hasMore?: boolean;
|
|
||||||
loadingMore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = ({
|
|
||||||
options,
|
|
||||||
selectedValues,
|
|
||||||
onChange,
|
|
||||||
placeholder = "انتخاب کنید...",
|
|
||||||
label,
|
|
||||||
error,
|
|
||||||
isLoading = false,
|
|
||||||
disabled = false,
|
|
||||||
onSearchChange,
|
|
||||||
onLoadMore,
|
|
||||||
hasMore = false,
|
|
||||||
loadingMore = false,
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const filteredOptions = options.filter(option =>
|
|
||||||
option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
||||||
);
|
|
||||||
|
|
||||||
// If parent provides onSearchChange, assume server-side filtering and use options as-is
|
|
||||||
const displayedOptions = onSearchChange ? options : filteredOptions;
|
|
||||||
|
|
||||||
const selectedOptions = options.filter(option => selectedValues.includes(option.id));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearchTerm('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleOption = (optionId: number) => {
|
|
||||||
if (selectedValues.includes(optionId)) {
|
|
||||||
onChange(selectedValues.filter(id => id !== optionId));
|
|
||||||
} else {
|
|
||||||
onChange([...selectedValues, optionId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveOption = (optionId: number) => {
|
|
||||||
onChange(selectedValues.filter(id => id !== optionId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleDropdown = () => {
|
|
||||||
if (disabled) return;
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selected Items Display */}
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
w-full px-3 py-3 text-base border rounded-lg
|
|
||||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
|
||||||
cursor-pointer transition-all duration-200
|
|
||||||
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
|
|
||||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
|
||||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
|
||||||
`}
|
|
||||||
onClick={handleToggleDropdown}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1 items-center">
|
|
||||||
{selectedOptions.length > 0 ? (
|
|
||||||
selectedOptions.map(option => (
|
|
||||||
<span
|
|
||||||
key={option.id}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-xs rounded-md"
|
|
||||||
>
|
|
||||||
{option.title || option.description || `#${option.id}`}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveOption(option.id);
|
|
||||||
}}
|
|
||||||
className="hover:bg-primary-200 dark:hover:bg-primary-700 rounded-full p-0.5 transition-colors"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">{placeholder}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-[60px]">
|
|
||||||
|
|
||||||
{isOpen && !disabled && (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSearchTerm(value);
|
|
||||||
if (onSearchChange) onSearchChange(value);
|
|
||||||
}}
|
|
||||||
className="w-full border-none outline-none bg-transparent text-sm"
|
|
||||||
placeholder="جستجو..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown */}
|
|
||||||
{isOpen && !disabled && (
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
onScroll={() => {
|
|
||||||
const el = listRef.current;
|
|
||||||
if (!el || !onLoadMore || !hasMore || loadingMore) return;
|
|
||||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
|
|
||||||
if (nearBottom) onLoadMore();
|
|
||||||
}}
|
|
||||||
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
در حال بارگذاری...
|
|
||||||
</div>
|
|
||||||
) : displayedOptions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{displayedOptions.map(option => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className={`
|
|
||||||
px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
|
|
||||||
${selectedValues.includes(option.id) ? 'bg-primary-200 dark:bg-primary-700/70' : ''}
|
|
||||||
`}
|
|
||||||
onClick={() => handleToggleOption(option.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{option.title}
|
|
||||||
</div>
|
|
||||||
{option.description && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{option.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedValues.includes(option.id) && (
|
|
||||||
<div className="text-primary-600 dark:text-primary-400">✓</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{onLoadMore && hasMore && (
|
|
||||||
<div className="p-2 text-center text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{loadingMore ? 'در حال بارگذاری بیشتر...' : 'اسکرول برای مشاهده بیشتر'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
موردی یافت نشد
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { ChevronDown, X } from 'lucide-react';
|
|
||||||
|
|
||||||
export interface Option {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SingleSelectAutocompleteProps {
|
|
||||||
options: Option[];
|
|
||||||
selectedValue?: number;
|
|
||||||
onChange: (value?: number) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onSearchChange?: (query: string) => void;
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
hasMore?: boolean;
|
|
||||||
loadingMore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SingleSelectAutocomplete: React.FC<SingleSelectAutocompleteProps> = ({
|
|
||||||
options,
|
|
||||||
selectedValue,
|
|
||||||
onChange,
|
|
||||||
placeholder = "انتخاب کنید...",
|
|
||||||
label,
|
|
||||||
error,
|
|
||||||
isLoading = false,
|
|
||||||
disabled = false,
|
|
||||||
onSearchChange,
|
|
||||||
onLoadMore,
|
|
||||||
hasMore = false,
|
|
||||||
loadingMore = false,
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const filteredOptions = options.filter(option =>
|
|
||||||
option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayedOptions = onSearchChange ? options : filteredOptions;
|
|
||||||
const selectedOption = options.find(option => option.id === selectedValue);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearchTerm('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectOption = (optionId: number) => {
|
|
||||||
onChange(optionId);
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearchTerm('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSelection = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleDropdown = () => {
|
|
||||||
if (disabled) return;
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setSearchTerm(value);
|
|
||||||
if (onSearchChange) {
|
|
||||||
onSearchChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
|
||||||
if (scrollHeight - scrollTop <= clientHeight + 5 && hasMore && !loadingMore && onLoadMore) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
w-full px-3 py-3 text-base border rounded-lg
|
|
||||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
|
||||||
cursor-pointer transition-all duration-200
|
|
||||||
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
|
|
||||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
|
||||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
|
||||||
`}
|
|
||||||
onClick={handleToggleDropdown}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
{selectedOption ? (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium">{selectedOption.title}</span>
|
|
||||||
{selectedOption.description && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 block">
|
|
||||||
{selectedOption.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearSelection}
|
|
||||||
className="ml-2 p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">{placeholder}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg">
|
|
||||||
<div className="p-2 border-b border-gray-200 dark:border-gray-600">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
placeholder="جستجو..."
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-600 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
className="max-h-60 overflow-y-auto"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
{isLoading && displayedOptions.length === 0 ? (
|
|
||||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
در حال بارگذاری...
|
|
||||||
</div>
|
|
||||||
) : displayedOptions.length === 0 ? (
|
|
||||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
موردی یافت نشد
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{displayedOptions.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className={`
|
|
||||||
p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-600
|
|
||||||
${selectedValue === option.id ? 'bg-blue-50 dark:bg-blue-900' : ''}
|
|
||||||
`}
|
|
||||||
onClick={() => handleSelectOption(option.id)}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-sm">{option.title}</div>
|
|
||||||
{option.description && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{option.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loadingMore && (
|
|
||||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
در حال بارگذاری بیشتر...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type StatusType = 'product' | 'order' | 'user' | 'discount' | 'comment' | 'generic';
|
|
||||||
|
|
||||||
export type ProductStatus = 'active' | 'inactive' | 'draft';
|
|
||||||
export type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
|
||||||
export type UserStatus = 'verified' | 'unverified' | boolean;
|
|
||||||
export type DiscountStatus = 'active' | 'inactive';
|
|
||||||
export type CommentStatus = 'approved' | 'rejected' | 'pending';
|
|
||||||
|
|
||||||
export type StatusValue = ProductStatus | OrderStatus | UserStatus | DiscountStatus | CommentStatus | string;
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status: StatusValue;
|
|
||||||
type?: StatusType;
|
|
||||||
className?: string;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusConfig = (status: StatusValue, type?: StatusType) => {
|
|
||||||
// Handle boolean status (for verified/unverified)
|
|
||||||
if (typeof status === 'boolean') {
|
|
||||||
return {
|
|
||||||
color: status
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
||||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
||||||
text: status ? 'تأیید شده' : 'تأیید نشده',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusStr = String(status).toLowerCase();
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'product':
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'active':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'فعال',
|
|
||||||
};
|
|
||||||
case 'inactive':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
text: 'غیرفعال',
|
|
||||||
};
|
|
||||||
case 'draft':
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: 'پیشنویس',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'order':
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'pending':
|
|
||||||
return {
|
|
||||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
||||||
text: 'در انتظار',
|
|
||||||
};
|
|
||||||
case 'processing':
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
|
||||||
text: 'در حال پردازش',
|
|
||||||
};
|
|
||||||
case 'shipped':
|
|
||||||
return {
|
|
||||||
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
|
||||||
text: 'ارسال شده',
|
|
||||||
};
|
|
||||||
case 'delivered':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'تحویل شده',
|
|
||||||
};
|
|
||||||
case 'cancelled':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
text: 'لغو شده',
|
|
||||||
};
|
|
||||||
case 'refunded':
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: 'مرجوع شده',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'user':
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'verified':
|
|
||||||
case 'true':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'تأیید شده',
|
|
||||||
};
|
|
||||||
case 'unverified':
|
|
||||||
case 'false':
|
|
||||||
return {
|
|
||||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
||||||
text: 'تأیید نشده',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'discount':
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'active':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'فعال',
|
|
||||||
};
|
|
||||||
case 'inactive':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
text: 'غیرفعال',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'comment':
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'approved':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'تأیید شده',
|
|
||||||
};
|
|
||||||
case 'rejected':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
text: 'رد شده',
|
|
||||||
};
|
|
||||||
case 'pending':
|
|
||||||
return {
|
|
||||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
||||||
text: 'در انتظار',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Generic status handling
|
|
||||||
switch (statusStr) {
|
|
||||||
case 'active':
|
|
||||||
case 'true':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
||||||
text: 'فعال',
|
|
||||||
};
|
|
||||||
case 'inactive':
|
|
||||||
case 'false':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
||||||
text: 'غیرفعال',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
||||||
text: statusStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
|
||||||
switch (size) {
|
|
||||||
case 'sm':
|
|
||||||
return 'px-2 py-0.5 text-xs';
|
|
||||||
case 'md':
|
|
||||||
return 'px-2.5 py-0.5 text-xs';
|
|
||||||
case 'lg':
|
|
||||||
return 'px-3 py-1 text-sm';
|
|
||||||
default:
|
|
||||||
return 'px-2.5 py-0.5 text-xs';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
|
||||||
status,
|
|
||||||
type = 'generic',
|
|
||||||
className = '',
|
|
||||||
size = 'md',
|
|
||||||
}) => {
|
|
||||||
const config = getStatusConfig(status, type);
|
|
||||||
const sizeClasses = getSizeClasses(size);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses} ${className}`}
|
|
||||||
>
|
|
||||||
{config.text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden md:block card overflow-x-auto">
|
<div className="hidden md:block card overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -83,22 +83,15 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
||||||
<th
|
<th
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'px-6 py-3 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
|
'px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
|
||||||
column.align === 'left' && 'text-left',
|
|
||||||
column.align === 'center' && 'text-center',
|
|
||||||
(!column.align || column.align === 'right') && 'text-right',
|
|
||||||
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
|
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||||
)}
|
)}
|
||||||
onClick={() => column.sortable && handleSort(column.key)}
|
onClick={() => column.sortable && handleSort(column.key)}
|
||||||
>
|
>
|
||||||
<div className={clsx('flex items-center space-x-1',
|
<div className="flex items-center justify-end space-x-1">
|
||||||
column.align === 'left' && 'justify-start',
|
<span>{column.label}</span>
|
||||||
column.align === 'center' && 'justify-center',
|
|
||||||
(!column.align || column.align === 'right') && 'justify-end'
|
|
||||||
)}>
|
|
||||||
<span style={{ width: '100%', textAlign: 'right' }}>{column.label}</span>
|
|
||||||
{column.sortable && (
|
{column.sortable && (
|
||||||
<div className="flex flex-col ml-1">
|
<div className="flex flex-col">
|
||||||
<ChevronUp
|
<ChevronUp
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-3 w-3',
|
'h-3 w-3',
|
||||||
|
|
@ -126,12 +119,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
||||||
{sortedData.map((row, rowIndex) => (
|
{sortedData.map((row, rowIndex) => (
|
||||||
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<td key={column.key} className={clsx(
|
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right">
|
||||||
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
|
|
||||||
column.align === 'left' && 'text-left',
|
|
||||||
column.align === 'center' && 'text-center',
|
|
||||||
(!column.align || column.align === 'right') && 'text-right'
|
|
||||||
)}>
|
|
||||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import React, { useState, KeyboardEvent } from 'react';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface TagInputProps {
|
|
||||||
values: string[];
|
|
||||||
onChange: (values: string[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TagInput: React.FC<TagInputProps> = ({
|
|
||||||
values,
|
|
||||||
onChange,
|
|
||||||
placeholder = "اضافه کنید و Enter بزنید...",
|
|
||||||
label,
|
|
||||||
error,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
|
|
||||||
const addValue = (value: string) => {
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
if (trimmedValue && !values.includes(trimmedValue)) {
|
|
||||||
onChange([...values, trimmedValue]);
|
|
||||||
setInputValue('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeValue = (index: number) => {
|
|
||||||
const newValues = values.filter((_, i) => i !== index);
|
|
||||||
onChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addValue(inputValue);
|
|
||||||
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
|
|
||||||
removeValue(values.length - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputBlur = () => {
|
|
||||||
if (inputValue.trim()) {
|
|
||||||
addValue(inputValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
|
||||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
|
||||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
|
||||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1 items-center">
|
|
||||||
{values.map((value, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 bg-primary-100 dark:bg-primary-500/30 text-primary-900 dark:text-white text-sm rounded-md border border-primary-200 dark:border-primary-500/60"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
{!disabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeValue(index)}
|
|
||||||
className="hover:bg-primary-200 dark:hover:bg-primary-500/50 rounded-full p-0.5"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
placeholder={values.length === 0 ? placeholder : ""}
|
|
||||||
className="flex-1 min-w-[120px] border-none outline-none bg-transparent text-sm dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Enter بزنید یا روی جای دیگری کلیک کنید تا مقدار اضافه شود
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<label className={`flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
|
||||||
checked
|
|
||||||
? 'bg-primary-600'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600'
|
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
|
|
||||||
checked ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface TypographyProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LabelProps extends TypographyProps {
|
|
||||||
htmlFor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page Headers
|
|
||||||
export const PageTitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<h1 className={`text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PageSubtitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<p className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 mt-1 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section Headers
|
|
||||||
export const SectionTitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<h2 className={`text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SectionSubtitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<h3 className={`text-base sm:text-lg font-medium text-gray-900 dark:text-gray-100 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Card Headers
|
|
||||||
export const CardTitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<h3 className={`text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CardSubtitle = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<p className={`text-sm text-gray-600 dark:text-gray-400 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stats and Values
|
|
||||||
export const StatValue = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<div className={`text-lg sm:text-xl lg:text-2xl font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const StatLabel = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<dt className={`text-xs sm:text-sm font-medium text-gray-500 dark:text-gray-400 truncate ${className}`}>
|
|
||||||
{children}
|
|
||||||
</dt>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Body Text
|
|
||||||
export const BodyText = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<p className={`text-sm sm:text-base text-gray-700 dark:text-gray-300 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SmallText = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<p className={`text-xs sm:text-sm text-gray-600 dark:text-gray-400 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
export const Label = ({ children, htmlFor, className = '' }: LabelProps) => (
|
|
||||||
<label htmlFor={htmlFor} className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Form Headers with Mobile Support
|
|
||||||
interface FormHeaderProps {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
backButton?: React.ReactNode;
|
|
||||||
actions?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormHeader = ({ title, subtitle, backButton, actions, className = '' }: FormHeaderProps) => (
|
|
||||||
<div className={`space-y-3 sm:space-y-4 ${className}`}>
|
|
||||||
{/* Mobile: Stack vertically, Desktop: Side by side */}
|
|
||||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:gap-4 sm:space-y-0">
|
|
||||||
{backButton && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{backButton}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<PageTitle className="break-words">{title}</PageTitle>
|
|
||||||
{subtitle && <PageSubtitle className="break-words">{subtitle}</PageSubtitle>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Page Container with consistent mobile spacing
|
|
||||||
export const PageContainer = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<div className={`space-y-6 max-w-none ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mobile-friendly card container
|
|
||||||
export const MobileCard = ({ children, className = '' }: TypographyProps) => (
|
|
||||||
<div className={`card p-3 sm:p-4 lg:p-6 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
@ -1,696 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Plus, Trash2, Edit3, Package, X, Edit, Image as ImageIcon } from 'lucide-react';
|
|
||||||
import { ProductVariantFormData, ProductImage } from '../../pages/products/core/_models';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { FileUploader } from './FileUploader';
|
|
||||||
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
|
|
||||||
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
|
|
||||||
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
|
|
||||||
const toPublicUrl = (img: any): ProductImage => {
|
|
||||||
const rawUrl: string = img?.url || '';
|
|
||||||
const serveKey: string | undefined = (img && img.serve_key) || undefined;
|
|
||||||
const url = serveKey
|
|
||||||
? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`
|
|
||||||
: rawUrl?.startsWith('http')
|
|
||||||
? rawUrl
|
|
||||||
: rawUrl
|
|
||||||
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
|
|
||||||
: '';
|
|
||||||
return {
|
|
||||||
id: (img?.id ?? img).toString(),
|
|
||||||
url,
|
|
||||||
alt: img?.alt || '',
|
|
||||||
order: img?.order ?? 0,
|
|
||||||
type: img?.mime_type || img?.type,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMAGE_MAX_SIZE = 2 * 1024 * 1024;
|
|
||||||
const VIDEO_MAX_SIZE = 25 * 1024 * 1024;
|
|
||||||
|
|
||||||
const isImageFileType = (file: File) => file.type?.startsWith('image/');
|
|
||||||
const isVideoFileType = (file: File) => file.type?.startsWith('video/');
|
|
||||||
|
|
||||||
const validateVariantMedia = (file: File) => {
|
|
||||||
if (isImageFileType(file)) {
|
|
||||||
if (file.size > IMAGE_MAX_SIZE) {
|
|
||||||
throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد');
|
|
||||||
}
|
|
||||||
} else if (isVideoFileType(file)) {
|
|
||||||
if (file.size > VIDEO_MAX_SIZE) {
|
|
||||||
throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('فقط تصاویر یا ویدیو مجاز است');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProductOption {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VariantManagerProps {
|
|
||||||
variants: ProductVariantFormData[];
|
|
||||||
onChange: (variants: ProductVariantFormData[]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
productOptions?: ProductOption[];
|
|
||||||
variantAttributeName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VariantFormProps {
|
|
||||||
variant?: ProductVariantFormData;
|
|
||||||
onSave: (variant: ProductVariantFormData) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isEdit?: boolean;
|
|
||||||
productOptions?: ProductOption[];
|
|
||||||
variantAttributeName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [], variantAttributeName }) => {
|
|
||||||
const [formData, setFormData] = useState<ProductVariantFormData>(
|
|
||||||
variant || {
|
|
||||||
enabled: true,
|
|
||||||
fee_percentage: 0,
|
|
||||||
profit_percentage: 0,
|
|
||||||
tax_percentage: 0,
|
|
||||||
stock_limit: 0,
|
|
||||||
stock_managed: true,
|
|
||||||
stock_number: 0,
|
|
||||||
weight: 0,
|
|
||||||
attributes: {},
|
|
||||||
meta: {},
|
|
||||||
file_ids: []
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>(
|
|
||||||
Array.isArray(variant?.file_ids) && variant.file_ids.length > 0 && typeof variant.file_ids[0] === 'object'
|
|
||||||
? variant.file_ids.map(toPublicUrl)
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
const [variantAttributeValue, setVariantAttributeValue] = useState('');
|
|
||||||
const [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
|
|
||||||
const [newMetaKey, setNewMetaKey] = useState('');
|
|
||||||
const [newMetaValue, setNewMetaValue] = useState('');
|
|
||||||
const [attributeError, setAttributeError] = useState('');
|
|
||||||
const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || '');
|
|
||||||
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
|
|
||||||
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
|
|
||||||
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
|
|
||||||
|
|
||||||
const { mutateAsync: uploadFile } = useFileUpload();
|
|
||||||
const { mutate: deleteFile } = useFileDelete();
|
|
||||||
|
|
||||||
// Sync formData.file_ids with uploadedImages
|
|
||||||
useEffect(() => {
|
|
||||||
setFormData(prev => ({ ...prev, file_ids: uploadedImages }));
|
|
||||||
}, [uploadedImages]);
|
|
||||||
|
|
||||||
// Sync display states with formData when editing
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant?.weight !== undefined) {
|
|
||||||
setWeightDisplay(variant.weight.toString());
|
|
||||||
}
|
|
||||||
if (variant?.fee_percentage !== undefined) {
|
|
||||||
setFeePercentageDisplay(variant.fee_percentage.toString());
|
|
||||||
}
|
|
||||||
if (variant?.profit_percentage !== undefined) {
|
|
||||||
setProfitPercentageDisplay(variant.profit_percentage.toString());
|
|
||||||
}
|
|
||||||
if (variant?.tax_percentage !== undefined) {
|
|
||||||
setTaxPercentageDisplay(variant.tax_percentage.toString());
|
|
||||||
}
|
|
||||||
// Load variant attribute value if exists
|
|
||||||
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
|
|
||||||
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
|
|
||||||
}
|
|
||||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]);
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
value = persianToEnglish(value);
|
|
||||||
}
|
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
|
||||||
try {
|
|
||||||
validateVariantMedia(file);
|
|
||||||
const result = await uploadFile(file);
|
|
||||||
setUploadedImages(prev => {
|
|
||||||
const newImage: ProductImage = {
|
|
||||||
id: result.id,
|
|
||||||
url: result.url,
|
|
||||||
alt: file.name,
|
|
||||||
order: prev.length,
|
|
||||||
type: result.mimeType || file.type
|
|
||||||
};
|
|
||||||
return [...prev, newImage];
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error?.message || 'خطا در آپلود فایل');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileRemove = (fileId: string) => {
|
|
||||||
const updatedImages = uploadedImages.filter(img => img.id !== fileId);
|
|
||||||
setUploadedImages(updatedImages);
|
|
||||||
deleteFile(fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleAddMeta = () => {
|
|
||||||
if (newMetaKey.trim() && newMetaValue.trim()) {
|
|
||||||
const updatedMeta = {
|
|
||||||
...meta,
|
|
||||||
[newMetaKey.trim()]: newMetaValue.trim()
|
|
||||||
};
|
|
||||||
setMeta(updatedMeta);
|
|
||||||
setNewMetaKey('');
|
|
||||||
setNewMetaValue('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveMeta = (key: string) => {
|
|
||||||
const updatedMeta = { ...meta };
|
|
||||||
delete updatedMeta[key];
|
|
||||||
setMeta(updatedMeta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
// Reset previous errors
|
|
||||||
setAttributeError('');
|
|
||||||
|
|
||||||
// Validate attribute value when attribute name is defined
|
|
||||||
if (variantAttributeName && !variantAttributeValue.trim()) {
|
|
||||||
setAttributeError(`مقدار ${variantAttributeName} الزامی است.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// نگه داشتن آبجکت کامل تصویر برای نمایش در لیست و حالت ویرایش
|
|
||||||
const fileObjects = uploadedImages;
|
|
||||||
|
|
||||||
// Create attributes object with single key-value pair
|
|
||||||
const attributes = variantAttributeName && variantAttributeValue.trim()
|
|
||||||
? { [variantAttributeName]: variantAttributeValue.trim() }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const convertedData = convertPersianNumbersInObject({
|
|
||||||
...formData,
|
|
||||||
attributes,
|
|
||||||
meta,
|
|
||||||
file_ids: fileObjects
|
|
||||||
});
|
|
||||||
|
|
||||||
onSave(convertedData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Basic Info */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
درصد کارمزد
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={feePercentageDisplay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setFeePercentageDisplay(converted);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
const numValue = parseFloat(converted) || 0;
|
|
||||||
handleInputChange('fee_percentage', numValue);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۵.۲"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
درصد سود
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={profitPercentageDisplay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setProfitPercentageDisplay(converted);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
const numValue = parseFloat(converted) || 0;
|
|
||||||
handleInputChange('profit_percentage', numValue);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۱۰.۵"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
درصد مالیات
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={taxPercentageDisplay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setTaxPercentageDisplay(converted);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
const numValue = parseFloat(converted) || 0;
|
|
||||||
handleInputChange('tax_percentage', numValue);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۹"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
وزن (گرم)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={weightDisplay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
setWeightDisplay(converted);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
const numValue = parseFloat(converted) || 0;
|
|
||||||
handleInputChange('weight', numValue);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۱۲۰۰.۵"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Stock Management */}
|
|
||||||
<div>
|
|
||||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
مدیریت موجودی
|
|
||||||
</h5>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.stock_managed}
|
|
||||||
onChange={(e) => handleInputChange('stock_managed', e.target.checked)}
|
|
||||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
مدیریت موجودی فعال باشد
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
تعداد موجودی
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={formData.stock_number || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
handleInputChange('stock_number', parseInt(converted) || 0);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۱۰۰"
|
|
||||||
disabled={!formData.stock_managed}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
حد موجودی
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={formData.stock_limit || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const converted = persianToEnglish(e.target.value);
|
|
||||||
handleInputChange('stock_limit', parseInt(converted) || 0);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
placeholder="مثال: ۱۰"
|
|
||||||
disabled={!formData.stock_managed}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Images */}
|
|
||||||
<div>
|
|
||||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
تصاویر Variant
|
|
||||||
</h5>
|
|
||||||
<FileUploader
|
|
||||||
onUpload={handleFileUpload}
|
|
||||||
onRemove={handleFileRemove}
|
|
||||||
acceptedTypes={['image/*', 'video/*']}
|
|
||||||
maxFileSize={25 * 1024 * 1024}
|
|
||||||
maxFiles={5}
|
|
||||||
label=""
|
|
||||||
description="فایلهای تصویری یا ویدیویی مخصوص این Variant را آپلود کنید"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{uploadedImages.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{uploadedImages.map((image, index) => (
|
|
||||||
<div key={image.id} className="relative group">
|
|
||||||
{image.type?.startsWith('video') ? (
|
|
||||||
<video
|
|
||||||
src={image.url}
|
|
||||||
className="w-full h-20 object-cover rounded-lg border"
|
|
||||||
controls
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={image.url}
|
|
||||||
alt={image.alt || `تصویر ${index + 1}`}
|
|
||||||
className="w-full h-20 object-cover rounded-lg border"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleFileRemove(image.id)}
|
|
||||||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Variant Attribute */}
|
|
||||||
{variantAttributeName && (
|
|
||||||
<div>
|
|
||||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
ویژگی Variant
|
|
||||||
</h5>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{variantAttributeName}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={variantAttributeValue}
|
|
||||||
onChange={(e) => setVariantAttributeValue(e.target.value)}
|
|
||||||
placeholder={`مقدار ${variantAttributeName} را وارد کنید`}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{attributeError && (
|
|
||||||
<p className="text-red-500 text-xs mt-1">{attributeError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Meta Data */}
|
|
||||||
<div>
|
|
||||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
||||||
Meta Data
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className="flex gap-3 mb-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMetaKey}
|
|
||||||
onChange={(e) => setNewMetaKey(e.target.value)}
|
|
||||||
placeholder="کلید Meta"
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMetaValue}
|
|
||||||
onChange={(e) => setNewMetaValue(e.target.value)}
|
|
||||||
placeholder="مقدار Meta"
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleAddMeta}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
افزودن
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(meta).length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{Object.entries(meta).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border">
|
|
||||||
<span className="text-sm">
|
|
||||||
<strong>{key}:</strong> {String(value)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveMeta(key)}
|
|
||||||
className="text-red-500 hover:text-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.enabled}
|
|
||||||
onChange={(e) => handleInputChange('enabled', e.target.checked)}
|
|
||||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Variant فعال باشد
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-600">
|
|
||||||
<Button variant="secondary" onClick={onCancel}>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave}>
|
|
||||||
{isEdit ? 'بهروزرسانی' : 'افزودن'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false, productOptions = [], variantAttributeName }) => {
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleAddVariant = () => {
|
|
||||||
setEditingIndex(null);
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditVariant = (index: number) => {
|
|
||||||
setEditingIndex(index);
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteVariant = (index: number) => {
|
|
||||||
const updatedVariants = variants.filter((_, i) => i !== index);
|
|
||||||
onChange(updatedVariants);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveVariant = (variant: ProductVariantFormData) => {
|
|
||||||
if (editingIndex !== null) {
|
|
||||||
// Edit existing variant
|
|
||||||
const updatedVariants = [...variants];
|
|
||||||
updatedVariants[editingIndex] = variant;
|
|
||||||
onChange(updatedVariants);
|
|
||||||
} else {
|
|
||||||
// Add new variant
|
|
||||||
onChange([...variants, variant]);
|
|
||||||
}
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelForm = () => {
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Variants محصول ({variants.length})
|
|
||||||
</h3>
|
|
||||||
{!disabled && !showForm && (
|
|
||||||
<Button onClick={handleAddVariant} className="flex items-center gap-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
افزودن Variant
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show Form */}
|
|
||||||
{showForm && (
|
|
||||||
<VariantForm
|
|
||||||
variant={editingIndex !== null ? variants[editingIndex] : undefined}
|
|
||||||
onSave={handleSaveVariant}
|
|
||||||
onCancel={handleCancelForm}
|
|
||||||
isEdit={editingIndex !== null}
|
|
||||||
productOptions={productOptions}
|
|
||||||
variantAttributeName={variantAttributeName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Variants List */}
|
|
||||||
{variants.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{variants.map((variant, index) => (
|
|
||||||
<div key={index} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-4 mb-2">
|
|
||||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Variant {index + 1}
|
|
||||||
</h4>
|
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${variant.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}>
|
|
||||||
{variant.enabled ? 'فعال' : 'غیرفعال'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div>
|
|
||||||
<strong>درصد کارمزد:</strong> {variant.fee_percentage}%
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>درصد سود:</strong> {variant.profit_percentage}%
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>درصد مالیات:</strong> {variant.tax_percentage}%
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>وزن:</strong> {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{variant.file_ids && variant.file_ids.length > 0 && (
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
{variant.file_ids.slice(0, 3).map((image, imgIndex) => (
|
|
||||||
<img
|
|
||||||
key={image.id}
|
|
||||||
src={image.url}
|
|
||||||
alt={image.alt || `تصویر ${imgIndex + 1}`}
|
|
||||||
className="w-12 h-12 object-cover rounded border"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{variant.file_ids.length > 3 && (
|
|
||||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-600 rounded border flex items-center justify-center text-xs">
|
|
||||||
+{variant.file_ids.length - 3}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show Attributes if any */}
|
|
||||||
{Object.keys(variant.attributes).length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ویژگیها:</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{Object.entries(variant.attributes).map(([key, value]) => (
|
|
||||||
<span key={key} className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
|
|
||||||
{key}: {String(value)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!disabled && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleEditVariant(index)}
|
|
||||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
|
|
||||||
title="ویرایش"
|
|
||||||
>
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteVariant(index)}
|
|
||||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
|
|
||||||
title="حذف"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variants.length === 0 && !showForm && (
|
|
||||||
<div className="text-center py-8 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
|
||||||
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
|
||||||
هنوز هیچ Variant ای اضافه نشده
|
|
||||||
</p>
|
|
||||||
{!disabled && (
|
|
||||||
<Button onClick={handleAddVariant} className="flex items-center gap-2 mx-auto">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
افزودن اولین Variant
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const pageSize = 10;
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
export const API_GATE_WAY = "https://apimznstg.aireview.ir";
|
|
||||||
export const ADMIN_API_PREFIX = "api/v1/admin";
|
|
||||||
|
|
||||||
export const REQUEST_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
export const API_ROUTES = {
|
|
||||||
// Auth APIs
|
|
||||||
ADMIN_LOGIN: "auth/login",
|
|
||||||
|
|
||||||
// Draft APIs (non-admin)
|
|
||||||
GET_DISCOUNT_DETAIL: (id: string) => `api/v1/discount-drafts/${id}`,
|
|
||||||
GET_DRAFT_DETAIL: (id: string) => `api/v1/drafts/${id}`,
|
|
||||||
|
|
||||||
// Admin Users APIs
|
|
||||||
GET_ADMIN_USERS: "admin-users",
|
|
||||||
GET_ADMIN_USER: (id: string) => `admin-users/${id}`,
|
|
||||||
CREATE_ADMIN_USER: "admin-users",
|
|
||||||
UPDATE_ADMIN_USER: (id: string) => `admin-users/${id}`,
|
|
||||||
DELETE_ADMIN_USER: (id: string) => `admin-users/${id}`,
|
|
||||||
|
|
||||||
// Roles APIs
|
|
||||||
GET_ROLES: "roles",
|
|
||||||
GET_ROLE: (id: string) => `roles/${id}`,
|
|
||||||
CREATE_ROLE: "roles",
|
|
||||||
UPDATE_ROLE: (id: string) => `roles/${id}`,
|
|
||||||
DELETE_ROLE: (id: string) => `roles/${id}`,
|
|
||||||
GET_ROLE_PERMISSIONS: (id: string) => `roles/${id}/permissions`,
|
|
||||||
ASSIGN_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
|
|
||||||
`roles/${roleId}/permissions/${permissionId}`,
|
|
||||||
REMOVE_ROLE_PERMISSION: (roleId: string, permissionId: string) =>
|
|
||||||
`roles/${roleId}/permissions/${permissionId}`,
|
|
||||||
|
|
||||||
// Permissions APIs
|
|
||||||
GET_PERMISSIONS: "permissions",
|
|
||||||
GET_PERMISSION: (id: string) => `permissions/${id}`,
|
|
||||||
CREATE_PERMISSION: "permissions",
|
|
||||||
UPDATE_PERMISSION: (id: string) => `permissions/${id}`,
|
|
||||||
DELETE_PERMISSION: (id: string) => `permissions/${id}`,
|
|
||||||
|
|
||||||
// Product Options APIs (non-admin)
|
|
||||||
GET_PRODUCT_OPTIONS: "products/options",
|
|
||||||
GET_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
|
|
||||||
CREATE_PRODUCT_OPTION: "products/options",
|
|
||||||
UPDATE_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
|
|
||||||
DELETE_PRODUCT_OPTION: (id: string) => `products/options/${id}`,
|
|
||||||
|
|
||||||
// Categories APIs (non-admin)
|
|
||||||
GET_CATEGORIES: "api/v1/products/categories",
|
|
||||||
GET_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
|
|
||||||
CREATE_CATEGORY: "api/v1/products/categories",
|
|
||||||
UPDATE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
|
|
||||||
DELETE_CATEGORY: (id: string) => `api/v1/products/categories/${id}`,
|
|
||||||
|
|
||||||
// Products APIs (non-admin)
|
|
||||||
GET_PRODUCTS: "api/v1/products",
|
|
||||||
GET_PRODUCT: (id: string) => `api/v1/products/${id}`,
|
|
||||||
CREATE_PRODUCT: "api/v1/products",
|
|
||||||
UPDATE_PRODUCT: (id: string) => `products/${id}`,
|
|
||||||
DELETE_PRODUCT: (id: string) => `api/v1/products/${id}`,
|
|
||||||
GET_PRODUCT_VARIANTS: (id: string) => `api/v1/products/${id}/variants`,
|
|
||||||
CREATE_PRODUCT_VARIANT: (id: string) => `api/v1/products/${id}/variants`,
|
|
||||||
UPDATE_PRODUCT_VARIANT: (variantId: string) =>
|
|
||||||
`api/v1/products/variants/${variantId}`,
|
|
||||||
DELETE_PRODUCT_VARIANT: (variantId: string) =>
|
|
||||||
`api/v1/products/variants/${variantId}`,
|
|
||||||
|
|
||||||
// Files APIs
|
|
||||||
GET_FILES: "files",
|
|
||||||
UPLOAD_FILE: "files",
|
|
||||||
GET_FILE: (id: string) => `files/${id}`,
|
|
||||||
UPDATE_FILE: (id: string) => `files/${id}`,
|
|
||||||
DELETE_FILE: (id: string) => `files/${id}`,
|
|
||||||
DOWNLOAD_FILE: (serveKey: string) => `api/v1/files/${serveKey}`, // non-admin
|
|
||||||
|
|
||||||
// Images APIs (non-admin)
|
|
||||||
GET_IMAGES: "api/v1/images",
|
|
||||||
CREATE_IMAGE: "api/v1/images",
|
|
||||||
UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
|
|
||||||
DELETE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
|
|
||||||
|
|
||||||
// Landing Hero APIs
|
|
||||||
GET_LANDING_HERO: "settings/landing/hero", // non-admin
|
|
||||||
UPDATE_LANDING_HERO: "settings/landing/hero", // admin
|
|
||||||
|
|
||||||
// Discount Codes APIs
|
|
||||||
GET_DISCOUNT_CODES: "discount/",
|
|
||||||
GET_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
|
|
||||||
CREATE_DISCOUNT_CODE: "discount/",
|
|
||||||
UPDATE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
|
|
||||||
DELETE_DISCOUNT_CODE: (id: string) => `discount/${id}/`,
|
|
||||||
|
|
||||||
// Orders APIs
|
|
||||||
GET_ORDERS: "checkout/orders",
|
|
||||||
GET_ORDER: (id: string) => `checkout/orders/${id}`,
|
|
||||||
GET_ORDER_STATS: "checkout/orders/statistics",
|
|
||||||
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
|
|
||||||
|
|
||||||
// Shipping Methods APIs
|
|
||||||
GET_SHIPPING_METHODS: "checkout/shipping-methods",
|
|
||||||
GET_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
|
|
||||||
CREATE_SHIPPING_METHOD: "checkout/shipping-methods",
|
|
||||||
UPDATE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
|
|
||||||
DELETE_SHIPPING_METHOD: (id: string) => `checkout/shipping-methods/${id}`,
|
|
||||||
|
|
||||||
// User Admin APIs
|
|
||||||
GET_USERS: "users",
|
|
||||||
GET_USER: (id: string) => `users/${id}`,
|
|
||||||
SEARCH_USERS: "users/search",
|
|
||||||
CREATE_USER: "users",
|
|
||||||
UPDATE_USER: (id: string) => `users/${id}`,
|
|
||||||
UPDATE_USER_PROFILE: (id: string) => `users/${id}/profile`,
|
|
||||||
UPDATE_USER_AVATAR: (id: string) => `users/${id}/avatar`,
|
|
||||||
DELETE_USER: (id: string) => `users/${id}`,
|
|
||||||
VERIFY_USER: (id: string) => `users/${id}/verify`,
|
|
||||||
UNVERIFY_USER: (id: string) => `users/${id}/unverify`,
|
|
||||||
GET_TICKETS: "tickets",
|
|
||||||
GET_TICKET: (id: string) => `tickets/${id}`,
|
|
||||||
CREATE_TICKET_REPLY: (id: string) => `tickets/${id}/messages`,
|
|
||||||
UPDATE_TICKET_STATUS: (id: string) => `tickets/${id}/status`,
|
|
||||||
ASSIGN_TICKET: (id: string) => `tickets/${id}/assign`,
|
|
||||||
GET_TICKET_DEPARTMENTS: "tickets/config/departments",
|
|
||||||
GET_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
|
|
||||||
CREATE_TICKET_DEPARTMENT: "tickets/config/departments",
|
|
||||||
UPDATE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
|
|
||||||
DELETE_TICKET_DEPARTMENT: (id: string) => `tickets/config/departments/${id}`,
|
|
||||||
GET_TICKET_STATUSES: "tickets/config/statuses",
|
|
||||||
GET_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
|
|
||||||
CREATE_TICKET_STATUS: "tickets/config/statuses",
|
|
||||||
UPDATE_TICKET_STATUS_CONFIG: (id: string) => `tickets/config/statuses/${id}`,
|
|
||||||
DELETE_TICKET_STATUS: (id: string) => `tickets/config/statuses/${id}`,
|
|
||||||
GET_TICKET_SUBJECTS: "tickets/config/subjects",
|
|
||||||
GET_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
|
||||||
CREATE_TICKET_SUBJECT: "tickets/config/subjects",
|
|
||||||
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
|
||||||
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
|
||||||
|
|
||||||
// Contact Us APIs
|
|
||||||
GET_CONTACT_US_MESSAGES: "contact-us",
|
|
||||||
DELETE_CONTACT_US_MESSAGE: (id: string) => `contact-us/${id}`,
|
|
||||||
|
|
||||||
// Payment IPG APIs
|
|
||||||
GET_IPG_STATUS: "payment/ipg/status",
|
|
||||||
UPDATE_IPG_STATUS: "payment/ipg/status",
|
|
||||||
|
|
||||||
// Payment Card APIs
|
|
||||||
GET_PAYMENT_CARD: "payment/card",
|
|
||||||
UPDATE_PAYMENT_CARD: "payment/card",
|
|
||||||
|
|
||||||
// Wallet APIs
|
|
||||||
GET_WALLET_STATUS: "wallet/status",
|
|
||||||
UPDATE_WALLET_STATUS: "wallet/status",
|
|
||||||
|
|
||||||
// Reports APIs
|
|
||||||
DISCOUNT_REPORTS: "reports/discounts",
|
|
||||||
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
|
|
||||||
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
|
|
||||||
PAYMENT_METHODS_REPORT: "reports/payments/methods",
|
|
||||||
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
|
|
||||||
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
|
|
||||||
SALES_GROWTH_REPORT: "reports/sales/growth",
|
|
||||||
USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth",
|
|
||||||
SALES_BY_CATEGORY_REPORT: "reports/sales/by-category",
|
|
||||||
|
|
||||||
// Product Comments APIs
|
|
||||||
GET_PRODUCT_COMMENTS: "products/comments",
|
|
||||||
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
|
|
||||||
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
|
|
||||||
};
|
|
||||||
|
|
@ -1,58 +1,41 @@
|
||||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
import { AuthState, AdminUser, Permission } from '../types/auth';
|
import { AuthState, User } from '../types';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType extends AuthState {
|
||||||
isAuthenticated: boolean;
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
isLoading: boolean;
|
|
||||||
user: AdminUser | null;
|
|
||||||
permissions: Permission[];
|
|
||||||
allPermissions: Permission[];
|
|
||||||
token: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
restoreSession: () => void;
|
hasPermission: (permission: number) => boolean;
|
||||||
hasPermission: (permissionId: number) => boolean;
|
|
||||||
hasPermissionByTitle: (title: string) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
type AuthAction =
|
type AuthAction =
|
||||||
| { type: 'LOGIN'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
|
| { type: 'LOGIN_SUCCESS'; payload: { user: User; token: string } }
|
||||||
| { type: 'LOGOUT' }
|
| { type: 'LOGOUT' }
|
||||||
| { type: 'RESTORE_SESSION'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
|
| { type: 'RESTORE_SESSION'; payload: { user: User; token: string } };
|
||||||
| { type: 'SET_LOADING'; payload: boolean };
|
|
||||||
|
|
||||||
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'LOGIN':
|
case 'LOGIN_SUCCESS':
|
||||||
case 'RESTORE_SESSION':
|
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
|
||||||
user: action.payload.user,
|
user: action.payload.user,
|
||||||
permissions: action.payload.permissions,
|
permissions: action.payload.user.permissions,
|
||||||
allPermissions: action.payload.allPermissions,
|
|
||||||
token: action.payload.token,
|
token: action.payload.token,
|
||||||
refreshToken: action.payload.refreshToken,
|
|
||||||
};
|
};
|
||||||
case 'LOGOUT':
|
case 'LOGOUT':
|
||||||
return {
|
return {
|
||||||
...state,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
|
||||||
user: null,
|
user: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
allPermissions: [],
|
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
|
||||||
};
|
};
|
||||||
case 'SET_LOADING':
|
case 'RESTORE_SESSION':
|
||||||
return {
|
return {
|
||||||
...state,
|
isAuthenticated: true,
|
||||||
isLoading: action.payload,
|
user: action.payload.user,
|
||||||
|
permissions: action.payload.user.permissions,
|
||||||
|
token: action.payload.token,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
@ -61,90 +44,79 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
|
||||||
user: null,
|
user: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
allPermissions: [],
|
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||||
|
|
||||||
const restoreSession = () => {
|
useEffect(() => {
|
||||||
dispatch({ type: 'SET_LOADING', payload: true });
|
|
||||||
|
|
||||||
const token = localStorage.getItem('admin_token');
|
const token = localStorage.getItem('admin_token');
|
||||||
const refreshToken = localStorage.getItem('admin_refresh_token');
|
|
||||||
const userStr = localStorage.getItem('admin_user');
|
const userStr = localStorage.getItem('admin_user');
|
||||||
const permissionsStr = localStorage.getItem('admin_permissions');
|
|
||||||
|
|
||||||
if (token && userStr && permissionsStr) {
|
if (token && userStr) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(userStr);
|
const user = JSON.parse(userStr);
|
||||||
const permissions = JSON.parse(permissionsStr);
|
dispatch({ type: 'RESTORE_SESSION', payload: { user, token } });
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'RESTORE_SESSION',
|
|
||||||
payload: {
|
|
||||||
user,
|
|
||||||
permissions,
|
|
||||||
allPermissions: permissions,
|
|
||||||
token,
|
|
||||||
refreshToken: refreshToken || ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
localStorage.removeItem('admin_token');
|
localStorage.removeItem('admin_token');
|
||||||
localStorage.removeItem('admin_refresh_token');
|
|
||||||
localStorage.removeItem('admin_user');
|
localStorage.removeItem('admin_user');
|
||||||
localStorage.removeItem('admin_permissions');
|
|
||||||
dispatch({ type: 'SET_LOADING', payload: false });
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
dispatch({ type: 'SET_LOADING', payload: false });
|
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: '1',
|
||||||
|
name: 'مدیر کل',
|
||||||
|
email: email,
|
||||||
|
role: 'admin',
|
||||||
|
permissions: [1, 2, 3, 4, 5, 10, 15, 20, 22, 25, 30],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastLogin: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const mockToken = 'mock-jwt-token-' + Date.now();
|
||||||
restoreSession();
|
|
||||||
}, []);
|
if (email === 'admin@test.com' && password === 'admin123') {
|
||||||
|
localStorage.setItem('admin_token', mockToken);
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(mockUser));
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
payload: { user: mockUser, token: mockToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('admin_token');
|
localStorage.removeItem('admin_token');
|
||||||
localStorage.removeItem('admin_refresh_token');
|
|
||||||
localStorage.removeItem('admin_user');
|
localStorage.removeItem('admin_user');
|
||||||
localStorage.removeItem('admin_permissions');
|
|
||||||
dispatch({ type: 'LOGOUT' });
|
dispatch({ type: 'LOGOUT' });
|
||||||
toast.success('خروج موفقیتآمیز بود');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasPermission = (permissionId: number): boolean => {
|
const hasPermission = (permission: number): boolean => {
|
||||||
// اگر Super Admin (id=1) باشد، به همه چیز دسترسی دارد
|
return state.permissions.includes(permission);
|
||||||
const isSuperAdmin = state.permissions.some(permission => permission.id === 1);
|
|
||||||
if (isSuperAdmin) return true;
|
|
||||||
|
|
||||||
// در غیر اینصورت چک کن permission مورد نیاز را دارد یا نه
|
|
||||||
return state.permissions.some(permission => permission.id === permissionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasPermissionByTitle = (title: string): boolean => {
|
|
||||||
// اگر Super Admin (AdminAll) باشد، به همه چیز دسترسی دارد
|
|
||||||
const isSuperAdmin = state.permissions.some(permission => permission.title === "AdminAll");
|
|
||||||
if (isSuperAdmin) return true;
|
|
||||||
|
|
||||||
// در غیر اینصورت چک کن permission مورد نیاز را دارد یا نه
|
|
||||||
return state.permissions.some(permission => permission.title === title);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
...state,
|
...state,
|
||||||
|
login,
|
||||||
logout,
|
logout,
|
||||||
restoreSession,
|
|
||||||
hasPermission,
|
hasPermission,
|
||||||
hasPermissionByTitle,
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,13 @@ export const ThemeProvider = ({ children }: { children: any }) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
|
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
const initialTheme = savedTheme || 'light';
|
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||||
setMode(initialTheme);
|
setMode(initialTheme);
|
||||||
|
|
||||||
if (initialTheme === 'dark') {
|
if (initialTheme === 'dark') {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { APIUrlGenerator } from "@/utils/baseHttpService";
|
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
|
||||||
import { httpPostRequest, httpDeleteRequest } from "@/utils/baseHttpService";
|
|
||||||
|
|
||||||
interface UploadResponse {
|
|
||||||
file: {
|
|
||||||
id: number;
|
|
||||||
url: string;
|
|
||||||
name: string;
|
|
||||||
original_name: string;
|
|
||||||
serve_key: string;
|
|
||||||
size: number;
|
|
||||||
mime_type: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFileUpload = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (file: File): Promise<{ id: string; url: string; mimeType?: string }> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("name", "uploaded-file");
|
|
||||||
|
|
||||||
console.log("Uploading file:", file.name);
|
|
||||||
|
|
||||||
const response = await httpPostRequest<UploadResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.UPLOAD_FILE),
|
|
||||||
formData,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Upload response:", response);
|
|
||||||
|
|
||||||
if (!response.data?.file) {
|
|
||||||
throw new Error("Invalid upload response");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: response.data.file.id.toString(),
|
|
||||||
url: response.data.file.url,
|
|
||||||
mimeType: response.data.file.mime_type,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("File upload error:", error);
|
|
||||||
toast.error(error?.message || "خطا در آپلود فایل");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useFileDelete = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (fileId: string) => {
|
|
||||||
const response = await httpDeleteRequest(
|
|
||||||
APIUrlGenerator(API_ROUTES.DELETE_FILE(fileId))
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("فایل با موفقیت حذف شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("File delete error:", error);
|
|
||||||
toast.error(error?.message || "خطا در حذف فایل");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -78,7 +78,7 @@ export const useCreateUser = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (userData: CreateUserRequest) =>
|
mutationFn: (userData: CreateUserRequest) =>
|
||||||
userService.createUser(userData),
|
userService.createUser(userData),
|
||||||
onSuccess: () => {
|
onSuccess: (response) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
toast.success("کاربر با موفقیت ایجاد شد");
|
toast.success("کاربر با موفقیت ایجاد شد");
|
||||||
},
|
},
|
||||||
|
|
@ -95,7 +95,7 @@ export const useUpdateUser = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
|
mutationFn: ({ id, data }: { id: string; data: UpdateUserRequest }) =>
|
||||||
userService.updateUser(id, data),
|
userService.updateUser(id, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (response, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
|
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
|
||||||
toast.success("کاربر با موفقیت ویرایش شد");
|
toast.success("کاربر با موفقیت ویرایش شد");
|
||||||
|
|
|
||||||
|
|
@ -30,36 +30,16 @@
|
||||||
body {
|
body {
|
||||||
background-color: #f9fafb;
|
background-color: #f9fafb;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
/* Prevent horizontal scrolling on mobile */
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
.dark body {
|
||||||
background-color: #111827;
|
background-color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure touch targets are large enough on mobile */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
button,
|
|
||||||
a,
|
|
||||||
[role="button"] {
|
|
||||||
min-height: 44px;
|
|
||||||
min-width: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Improve text selection on mobile */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
* {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 transition-shadow duration-200 hover:shadow-lg;
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
@ -73,76 +53,4 @@
|
||||||
.input {
|
.input {
|
||||||
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
|
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-specific utilities */
|
|
||||||
.mobile-container {
|
|
||||||
@apply px-4 sm:px-6 lg:px-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-card {
|
|
||||||
@apply card p-4 sm:p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Safe area for mobile devices */
|
|
||||||
.safe-area {
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile-specific form improvements */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
@apply text-base; /* Prevent zoom on iOS */
|
|
||||||
font-size: 16px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
@apply grid-cols-1 gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
@apply flex-col space-y-3 space-x-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group > * {
|
|
||||||
@apply w-full;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive text utilities */
|
|
||||||
.text-responsive-xs {
|
|
||||||
@apply text-xs sm:text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-responsive-sm {
|
|
||||||
@apply text-sm sm:text-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-responsive-base {
|
|
||||||
@apply text-base sm:text-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-responsive-lg {
|
|
||||||
@apply text-lg sm:text-xl lg:text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-responsive-xl {
|
|
||||||
@apply text-xl sm:text-2xl lg:text-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile chart container */
|
|
||||||
.chart-container {
|
|
||||||
@apply w-full overflow-hidden;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.chart-container {
|
|
||||||
min-height: 180px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
gcTime: 0,
|
retry: (failureCount, error: any) => {
|
||||||
staleTime: 0,
|
if (error?.response?.status === 404) return false;
|
||||||
refetchOnMount: true,
|
if (error?.response?.status === 403) return false;
|
||||||
|
if (error?.response?.status === 401) return false;
|
||||||
|
return failureCount < 2;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retry: 1,
|
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: 1,
|
onError: (error: any) => {
|
||||||
|
const message =
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
"خطایی رخ داده است";
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,159 @@
|
||||||
import { ApexAreaChartCard } from '../components/charts/ApexAreaChartCard';
|
import { Users, ShoppingBag, DollarSign, TrendingUp } from 'lucide-react';
|
||||||
import { ApexBarChartCard } from '../components/charts/ApexBarChartCard';
|
import { StatsCard } from '../components/dashboard/StatsCard';
|
||||||
import { useMemo } from 'react';
|
import { BarChart } from '../components/charts/BarChart';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { LineChart } from '../components/charts/LineChart';
|
||||||
import { useSalesGrowthReport, useUserRegistrationGrowthReport, useSalesByCategoryReport } from './reports/sales-statistics/core/_hooks';
|
|
||||||
import { useOrders } from './orders/core/_hooks';
|
|
||||||
import { StatusBadge } from '../components/ui/StatusBadge';
|
|
||||||
import { formatCurrency, formatDate } from '../utils/formatters';
|
|
||||||
|
|
||||||
import { PieChart } from '../components/charts/PieChart';
|
import { PieChart } from '../components/charts/PieChart';
|
||||||
import { Table } from '../components/ui/Table';
|
import { Table } from '../components/ui/Table';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||||
import { ChartData, TableColumn } from '../types';
|
import { ChartData, TableColumn } from '../types';
|
||||||
|
|
||||||
export const Dashboard = () => {
|
const statsData = [
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: salesGrowthReport } = useSalesGrowthReport({ group_by: 'month' });
|
|
||||||
const { data: registrationGrowthReport } = useUserRegistrationGrowthReport({ group_by: 'month' });
|
|
||||||
const { data: salesByCategoryReport } = useSalesByCategoryReport();
|
|
||||||
const recentOrdersFilters = useMemo(() => ({
|
|
||||||
page: 1,
|
|
||||||
limit: 5,
|
|
||||||
status: 'pending' as const,
|
|
||||||
}), []);
|
|
||||||
const { data: recentOrders, isLoading: isOrdersLoading } = useOrders(recentOrdersFilters);
|
|
||||||
|
|
||||||
const monthlySalesData: ChartData[] = useMemo(() => {
|
|
||||||
return (salesGrowthReport?.sales || []).map((item) => ({
|
|
||||||
name: item.month_name || `${item.year}/${item.month}`,
|
|
||||||
value: item.total_sales,
|
|
||||||
}));
|
|
||||||
}, [salesGrowthReport]);
|
|
||||||
|
|
||||||
const registrationGrowthData: ChartData[] = useMemo(() => {
|
|
||||||
return (registrationGrowthReport?.registrations || []).map((item) => ({
|
|
||||||
name: item.month_name || `${item.year}/${item.month}`,
|
|
||||||
value: item.total_users,
|
|
||||||
}));
|
|
||||||
}, [registrationGrowthReport]);
|
|
||||||
|
|
||||||
const salesByCategoryData: ChartData[] = useMemo(() => {
|
|
||||||
return (salesByCategoryReport?.categories || []).map((item) => ({
|
|
||||||
name: item.category_name,
|
|
||||||
value: item.percentage,
|
|
||||||
}));
|
|
||||||
}, [salesByCategoryReport]);
|
|
||||||
|
|
||||||
const orderColumns: TableColumn[] = [
|
|
||||||
{
|
{
|
||||||
key: 'order_number',
|
title: 'کل کاربران',
|
||||||
label: 'شماره سفارش',
|
value: 1247,
|
||||||
render: (value: string) => `#${value}`,
|
change: 12,
|
||||||
|
icon: Users,
|
||||||
|
color: 'blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'customer',
|
title: 'فروش ماهانه',
|
||||||
label: 'مشتری',
|
value: '۲۴,۵۶۷,۰۰۰',
|
||||||
render: (_value, row: any) => {
|
change: 8.5,
|
||||||
const customer = row.user || row.customer;
|
icon: DollarSign,
|
||||||
const name = `${customer?.first_name || ''} ${customer?.last_name || ''}`.trim();
|
color: 'green',
|
||||||
return name || 'نامشخص';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'final_total',
|
title: 'کل سفارشات',
|
||||||
label: 'مبلغ',
|
value: 356,
|
||||||
render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0),
|
change: -2.3,
|
||||||
|
icon: ShoppingBag,
|
||||||
|
color: 'yellow',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
title: 'رشد فروش',
|
||||||
label: 'وضعیت',
|
value: '۲۳.۵%',
|
||||||
render: (value: any) => <StatusBadge status={value} type="order" />,
|
change: 15.2,
|
||||||
},
|
icon: TrendingUp,
|
||||||
{
|
color: 'purple',
|
||||||
key: 'created_at',
|
|
||||||
label: 'تاریخ',
|
|
||||||
render: (value: string) => formatDate(value),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
|
const chartData: ChartData[] = [
|
||||||
|
{ name: 'فروردین', value: 4000 },
|
||||||
|
{ name: 'اردیبهشت', value: 3000 },
|
||||||
|
{ name: 'خرداد', value: 5000 },
|
||||||
|
{ name: 'تیر', value: 4500 },
|
||||||
|
{ name: 'مرداد', value: 6000 },
|
||||||
|
{ name: 'شهریور', value: 5500 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pieData: ChartData[] = [
|
||||||
|
{ name: 'دسکتاپ', value: 45 },
|
||||||
|
{ name: 'موبایل', value: 35 },
|
||||||
|
{ name: 'تبلت', value: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentUsers = [
|
||||||
|
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵' },
|
||||||
|
{ id: 2, name: 'فاطمه حسینی', email: 'fateme@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۴' },
|
||||||
|
{ id: 3, name: 'محمد رضایی', email: 'mohammad@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۳' },
|
||||||
|
{ id: 4, name: 'زهرا کریمی', email: 'zahra@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۲' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const userColumns: TableColumn[] = [
|
||||||
|
{ key: 'name', label: 'نام', sortable: true },
|
||||||
|
{ key: 'email', label: 'ایمیل' },
|
||||||
|
{ key: 'role', label: 'نقش' },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'وضعیت',
|
||||||
|
render: (value) => (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'فعال'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: 'createdAt', label: 'تاریخ عضویت' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'عملیات',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<PermissionWrapper permission={22}>
|
||||||
|
<Button size="sm" variant="danger">
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</PermissionWrapper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<div className="p-6 space-y-6">
|
||||||
{/* Header with mobile-responsive layout */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
<PageTitle>داشبورد</PageTitle>
|
داشبورد
|
||||||
|
</h1>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Button variant="secondary">
|
||||||
|
گزارشگیری
|
||||||
|
</Button>
|
||||||
|
<PermissionWrapper permission={25}>
|
||||||
|
<Button>
|
||||||
|
اضافه کردن
|
||||||
|
</Button>
|
||||||
|
</PermissionWrapper>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts - Better mobile layout */}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
{statsData.map((stat, index) => (
|
||||||
<div className="min-w-0">
|
<StatsCard key={index} {...stat} />
|
||||||
<ApexBarChartCard
|
))}
|
||||||
data={monthlySalesData}
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
title="فروش ماهانه"
|
title="فروش ماهانه"
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
/>
|
/>
|
||||||
</div>
|
<LineChart
|
||||||
<div className="min-w-0">
|
data={chartData}
|
||||||
<ApexAreaChartCard
|
title="روند رشد"
|
||||||
data={registrationGrowthData}
|
|
||||||
title="روند رشد ثبتنام کاربران"
|
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table and Pie Chart - Mobile responsive */}
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6">
|
<div className="lg:col-span-2">
|
||||||
<div className="xl:col-span-2 min-w-0">
|
<div className="card p-6">
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
کاربران اخیر
|
||||||
آخرین سفارشات در انتظار
|
</h3>
|
||||||
</CardTitle>
|
<Table
|
||||||
<div className="overflow-x-auto">
|
columns={userColumns}
|
||||||
<Table columns={orderColumns} data={ordersTableData} loading={isOrdersLoading} />
|
data={recentUsers}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<Button variant="secondary" onClick={() => navigate('/orders')}>
|
|
||||||
مشاهده همه
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div className="min-w-0">
|
|
||||||
<PieChart
|
<PieChart
|
||||||
data={salesByCategoryData}
|
data={pieData}
|
||||||
title="توزیع فروش بر اساس دستهبندی"
|
title="دستگاههای کاربری"
|
||||||
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, Lock, User } from 'lucide-react';
|
import { Eye, EyeOff, Lock, Mail } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Input } from '../components/ui/Input';
|
import { Input } from '../components/ui/Input';
|
||||||
import { loginSchema, LoginFormData } from '../utils/validationSchemas';
|
import { loginSchema, LoginFormData } from '../utils/validationSchemas';
|
||||||
import { useLogin } from './auth/core/_hooks';
|
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
const { isAuthenticated, isLoading, restoreSession } = useAuth();
|
const { isAuthenticated, login } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const { mutate: login, isPending: isLoggingIn } = useLogin();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
@ -26,33 +23,24 @@ export const Login = () => {
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
|
||||||
<p className="mt-4 text-gray-600 dark:text-gray-400">در حال بارگذاری...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
login(data, {
|
try {
|
||||||
onSuccess: () => {
|
const success = await login(data.email, data.password);
|
||||||
restoreSession();
|
if (!success) {
|
||||||
navigate('/');
|
setError('ایمیل یا رمز عبور اشتباه است');
|
||||||
},
|
}
|
||||||
onError: () => {
|
} catch (error) {
|
||||||
setError('نام کاربری یا رمز عبور اشتباه است');
|
setError('خطایی رخ داده است. لطفا دوباره تلاش کنید');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,12 +61,12 @@ export const Login = () => {
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="نام کاربری"
|
label="ایمیل"
|
||||||
type="text"
|
type="email"
|
||||||
placeholder="نام کاربری خود را وارد کنید"
|
placeholder="admin@test.com"
|
||||||
icon={User}
|
icon={Mail}
|
||||||
error={errors.username?.message}
|
error={errors.email?.message}
|
||||||
{...register('username')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -91,7 +79,7 @@ export const Login = () => {
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="رمز عبور خود را وارد کنید"
|
placeholder="admin123"
|
||||||
className={`input pr-10 pl-10 ${errors.password ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
|
className={`input pr-10 pl-10 ${errors.password ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
|
||||||
}`}
|
}`}
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
|
|
@ -122,9 +110,15 @@ export const Login = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 px-4 py-3 rounded-lg text-sm">
|
||||||
|
<p className="font-medium">اطلاعات تست:</p>
|
||||||
|
<p>ایمیل: admin@test.com</p>
|
||||||
|
<p>رمز عبور: admin123</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={isLoggingIn}
|
loading={loading}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Bell, BellOff, Clock, Eye } from 'lucide-react';
|
import { Bell, Check, X, Plus, Search, Filter, AlertCircle, Info, CheckCircle, XCircle } from 'lucide-react';
|
||||||
import { Table } from '../components/ui/Table';
|
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Pagination } from '../components/ui/Pagination';
|
import { Pagination } from '../components/ui/Pagination';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
|
||||||
import { TableColumn } from '../types';
|
|
||||||
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
|
|
||||||
|
|
||||||
const allNotifications = [
|
const allNotifications = [
|
||||||
{
|
{
|
||||||
|
|
@ -100,13 +96,13 @@ export const Notifications = () => {
|
||||||
const getNotificationIcon = (type: string) => {
|
const getNotificationIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return <BellOff className="h-5 w-5 text-red-600" />;
|
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <Bell className="h-5 w-5 text-yellow-600" />;
|
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
|
||||||
case 'success':
|
case 'success':
|
||||||
return <Bell className="h-5 w-5 text-green-600" />;
|
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||||
case 'info':
|
case 'info':
|
||||||
return <Eye className="h-5 w-5 text-blue-600" />;
|
return <Info className="h-5 w-5 text-blue-600" />;
|
||||||
default:
|
default:
|
||||||
return <Bell className="h-5 w-5 text-gray-600" />;
|
return <Bell className="h-5 w-5 text-gray-600" />;
|
||||||
}
|
}
|
||||||
|
|
@ -160,11 +156,16 @@ export const Notifications = () => {
|
||||||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<div className="p-6 space-y-6">
|
||||||
<PageTitle>اعلانات</PageTitle>
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<StatValue>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
اعلانات
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
|
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
|
||||||
</StatValue>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -172,7 +173,7 @@ export const Notifications = () => {
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={handleMarkAllAsRead}
|
||||||
disabled={unreadCount === 0}
|
disabled={unreadCount === 0}
|
||||||
>
|
>
|
||||||
<BellOff className="h-4 w-4 ml-2" />
|
<Check className="h-4 w-4 ml-2" />
|
||||||
همه را خوانده شده علامت بزن
|
همه را خوانده شده علامت بزن
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button>
|
||||||
|
|
@ -180,6 +181,7 @@ export const Notifications = () => {
|
||||||
اعلان جدید
|
اعلان جدید
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
|
@ -187,41 +189,41 @@ export const Notifications = () => {
|
||||||
<Bell className="h-8 w-8 text-blue-600" />
|
<Bell className="h-8 w-8 text-blue-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
|
||||||
<StatValue>{notifications.length}</StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<BellOff className="h-8 w-8 text-red-600" />
|
<AlertCircle className="h-8 w-8 text-red-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
|
||||||
<StatValue>{unreadCount}</StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<BellOff className="h-8 w-8 text-red-600" />
|
<XCircle className="h-8 w-8 text-red-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خطا</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خطا</p>
|
||||||
<StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{notifications.filter(n => n.type === 'error').length}
|
{notifications.filter(n => n.type === 'error').length}
|
||||||
</StatValue>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Bell className="h-8 w-8 text-yellow-600" />
|
<AlertCircle className="h-8 w-8 text-yellow-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">هشدار</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">هشدار</p>
|
||||||
<StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{notifications.filter(n => n.type === 'warning').length}
|
{notifications.filter(n => n.type === 'warning').length}
|
||||||
</StatValue>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -306,7 +308,7 @@ export const Notifications = () => {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
>
|
>
|
||||||
<BellOff className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -314,7 +316,7 @@ export const Notifications = () => {
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => handleDeleteNotification(notification.id)}
|
onClick={() => handleDeleteNotification(notification.id)}
|
||||||
>
|
>
|
||||||
<BellOff className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -337,6 +339,6 @@ export const Notifications = () => {
|
||||||
totalItems={filteredNotifications.length}
|
totalItems={filteredNotifications.length}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Search, Filter, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||||
|
import { Table } from '../components/ui/Table';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Pagination } from '../components/ui/Pagination';
|
||||||
|
import { TableColumn } from '../types';
|
||||||
|
|
||||||
|
const allOrders = [
|
||||||
|
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
|
||||||
|
{ id: 1002, customer: 'فاطمه حسینی', products: '۱ محصول', amount: '۲۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۴' },
|
||||||
|
{ id: 1003, customer: 'محمد رضایی', products: '۲ محصول', amount: '۳۲,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۱۳' },
|
||||||
|
{ id: 1004, customer: 'زهرا کریمی', products: '۵ محصول', amount: '۱۲۰,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۲' },
|
||||||
|
{ id: 1005, customer: 'حسن نوری', products: '۱ محصول', amount: '۱۸,۰۰۰,۰۰۰', status: 'لغو شده', date: '۱۴۰۲/۰۸/۱۱' },
|
||||||
|
{ id: 1006, customer: 'مریم صادقی', products: '۴ محصول', amount: '۸۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۱۰' },
|
||||||
|
{ id: 1007, customer: 'احمد قاسمی', products: '۲ محصول', amount: '۳۸,۰۰۰,۰۰۰', status: 'ارسال شده', date: '۱۴۰۲/۰۸/۰۹' },
|
||||||
|
{ id: 1008, customer: 'سارا محمدی', products: '۳ محصول', amount: '۶۲,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۸' },
|
||||||
|
{ id: 1009, customer: 'رضا کریمی', products: '۱ محصول', amount: '۱۵,۰۰۰,۰۰۰', status: 'در حال پردازش', date: '۱۴۰۲/۰۸/۰۷' },
|
||||||
|
{ id: 1010, customer: 'نرگس احمدی', products: '۶ محصول', amount: '۱۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۰۶' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Orders = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 6;
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ key: 'id', label: 'شماره سفارش', sortable: true },
|
||||||
|
{ key: 'customer', label: 'مشتری', sortable: true },
|
||||||
|
{ key: 'products', label: 'محصولات' },
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: 'مبلغ',
|
||||||
|
render: (value) => (
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{value} تومان
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'وضعیت',
|
||||||
|
render: (value) => (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'تحویل شده'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
: value === 'ارسال شده'
|
||||||
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||||
|
: value === 'در حال پردازش'
|
||||||
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ key: 'date', label: 'تاریخ سفارش', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'عملیات',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleViewOrder(row)}
|
||||||
|
>
|
||||||
|
مشاهده
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => handleEditOrder(row)}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredOrders = allOrders.filter((order: any) =>
|
||||||
|
order.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
order.id.toString().includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
|
const handleViewOrder = (order: any) => {
|
||||||
|
console.log('Viewing order:', order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOrder = (order: any) => {
|
||||||
|
console.log('Editing order:', order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalRevenue = allOrders.reduce((sum, order) => {
|
||||||
|
const amount = parseInt(order.amount.replace(/[,]/g, ''));
|
||||||
|
return sum + amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
مدیریت سفارشات
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{filteredOrders.length} سفارش یافت شد
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button variant="secondary">
|
||||||
|
<Filter className="h-4 w-4 ml-2" />
|
||||||
|
فیلتر
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ShoppingCart className="h-8 w-8 text-blue-600" />
|
||||||
|
<div className="mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allOrders.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TrendingUp className="h-8 w-8 text-green-600" />
|
||||||
|
<div className="mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">تحویل شده</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{allOrders.filter(o => o.status === 'تحویل شده').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ShoppingCart className="h-8 w-8 text-yellow-600" />
|
||||||
|
<div className="mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">در انتظار</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{allOrders.filter(o => o.status === 'در حال پردازش').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TrendingUp className="h-8 w-8 text-purple-600" />
|
||||||
|
<div className="mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل فروش</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{totalRevenue.toLocaleString()} تومان
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Search className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجو در سفارشات..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input pr-10 max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedOrders}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
totalItems={filteredOrders.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Plus, Search, Filter, Package } from 'lucide-react';
|
||||||
import { Package, Plus, Search, Filter, Eye, Edit, Trash2, Grid, List } from 'lucide-react';
|
|
||||||
import { Table } from '../components/ui/Table';
|
import { Table } from '../components/ui/Table';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Pagination } from '../components/ui/Pagination';
|
import { Pagination } from '../components/ui/Pagination';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||||
import { TableColumn } from '../types';
|
import { TableColumn } from '../types';
|
||||||
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
|
|
||||||
|
|
||||||
const allProducts = [
|
const allProducts = [
|
||||||
{ id: 1, name: 'لپتاپ ایسوس', category: 'کامپیوتر', price: '۲۵,۰۰۰,۰۰۰', stock: 15, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۵' },
|
{ id: 1, name: 'لپتاپ ایسوس', category: 'کامپیوتر', price: '۲۵,۰۰۰,۰۰۰', stock: 15, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۵' },
|
||||||
|
|
@ -21,6 +19,7 @@ const allProducts = [
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 6;
|
const itemsPerPage = 6;
|
||||||
|
|
||||||
|
|
@ -105,11 +104,16 @@ const Products = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<div className="p-6 space-y-6">
|
||||||
<PageTitle>مدیریت محصولات</PageTitle>
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
مدیریت محصولات
|
||||||
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{filteredProducts.length} محصول یافت شد
|
{filteredProducts.length} محصول یافت شد
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
|
|
@ -123,6 +127,7 @@ const Products = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</PermissionWrapper>
|
</PermissionWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||||
|
|
@ -130,7 +135,7 @@ const Products = () => {
|
||||||
<Package className="h-8 w-8 text-blue-600" />
|
<Package className="h-8 w-8 text-blue-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل محصولات</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل محصولات</p>
|
||||||
<StatValue>{allProducts.length}</StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,9 +145,9 @@ const Products = () => {
|
||||||
<Package className="h-8 w-8 text-green-600" />
|
<Package className="h-8 w-8 text-green-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات موجود</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات موجود</p>
|
||||||
<StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{allProducts.filter(p => p.status === 'موجود').length}
|
{allProducts.filter(p => p.status === 'موجود').length}
|
||||||
</StatValue>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,9 +157,9 @@ const Products = () => {
|
||||||
<Package className="h-8 w-8 text-red-600" />
|
<Package className="h-8 w-8 text-red-600" />
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات ناموجود</p>
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات ناموجود</p>
|
||||||
<StatValue>
|
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{allProducts.filter(p => p.status === 'ناموجود').length}
|
{allProducts.filter(p => p.status === 'ناموجود').length}
|
||||||
</StatValue>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,7 +185,7 @@ const Products = () => {
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={paginatedProducts}
|
data={paginatedProducts}
|
||||||
loading={false}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|
@ -191,7 +196,7 @@ const Products = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
import { FileText, Download, Calendar, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { BarChart } from '../components/charts/BarChart';
|
import { BarChart } from '../components/charts/BarChart';
|
||||||
import { lazy, Suspense } from 'react';
|
import { LineChart } from '../components/charts/LineChart';
|
||||||
|
|
||||||
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
|
|
||||||
|
|
||||||
export const Reports = () => {
|
export const Reports = () => {
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||||
|
|
@ -166,9 +164,7 @@ export const Reports = () => {
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
رشد کاربران
|
رشد کاربران
|
||||||
</h3>
|
</h3>
|
||||||
<Suspense fallback={<div className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-64" />}>
|
|
||||||
<LineChart data={userGrowthData} />
|
<LineChart data={userGrowthData} />
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
|
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
|
||||||
import { Input } from '../components/ui/Input';
|
import { Input } from '../components/ui/Input';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { PageHeader } from '../components/layout/PageHeader';
|
|
||||||
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
|
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
|
|
@ -44,11 +43,15 @@ export const Settings = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<PageHeader
|
<div className="mb-8">
|
||||||
title="تنظیمات سیستم"
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
|
||||||
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
|
<SettingsIcon className="h-6 w-6 ml-3" />
|
||||||
icon={SettingsIcon}
|
تنظیمات سیستم
|
||||||
/>
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
تنظیمات کلی سیستم را اینجا مدیریت کنید
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Users as UsersIcon, UserCheck, UserX } from 'lucide-react';
|
import { Plus, Search, Filter } from 'lucide-react';
|
||||||
import { Table } from '../components/ui/Table';
|
import { Table } from '../components/ui/Table';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
|
@ -8,7 +8,6 @@ import { UserForm } from '../components/forms/UserForm';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||||
import { TableColumn } from '../types';
|
import { TableColumn } from '../types';
|
||||||
import { UserFormData } from '../utils/validationSchemas';
|
import { UserFormData } from '../utils/validationSchemas';
|
||||||
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
|
|
||||||
|
|
||||||
const allUsers = [
|
const allUsers = [
|
||||||
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||||
|
|
@ -27,6 +26,7 @@ const allUsers = [
|
||||||
|
|
||||||
export const Users = () => {
|
export const Users = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [showUserModal, setShowUserModal] = useState(false);
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
const [editingUser, setEditingUser] = useState<any>(null);
|
const [editingUser, setEditingUser] = useState<any>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
@ -112,28 +112,27 @@ export const Users = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle>مدیریت کاربران</PageTitle>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
مدیریت کاربران
|
||||||
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{filteredUsers.length} کاربر یافت شد
|
{filteredUsers.length} کاربر یافت شد
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
<div className="flex items-center space-x-4">
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
<Filter className="h-4 w-4 ml-2" />
|
<Filter className="h-4 w-4 ml-2" />
|
||||||
فیلتر
|
فیلتر
|
||||||
</Button>
|
</Button>
|
||||||
<PermissionWrapper permission={25}>
|
<PermissionWrapper permission={25}>
|
||||||
<button
|
<Button onClick={handleAddUser}>
|
||||||
onClick={handleAddUser}
|
<Plus className="h-4 w-4 ml-2" />
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
افزودن کاربر
|
||||||
title="افزودن کاربر"
|
</Button>
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</PermissionWrapper>
|
</PermissionWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,7 +157,7 @@ export const Users = () => {
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={paginatedUsers}
|
data={paginatedUsers}
|
||||||
loading={false}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|
@ -171,7 +170,6 @@ export const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
|
||||||
isOpen={showUserModal}
|
isOpen={showUserModal}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -180,10 +178,10 @@ export const Users = () => {
|
||||||
initialData={editingUser}
|
initialData={editingUser}
|
||||||
onSubmit={handleSubmitUser}
|
onSubmit={handleSubmitUser}
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
loading={false}
|
loading={loading}
|
||||||
isEdit={!!editingUser}
|
isEdit={!!editingUser}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</PageContainer>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -6,11 +6,11 @@ import { Modal } from '../components/ui/Modal';
|
||||||
import { Pagination } from '../components/ui/Pagination';
|
import { Pagination } from '../components/ui/Pagination';
|
||||||
import { UserForm } from '../components/forms/UserForm';
|
import { UserForm } from '../components/forms/UserForm';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||||
|
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
|
||||||
import { TableColumn } from '../types';
|
import { TableColumn } from '../types';
|
||||||
import { UserFormData } from '../utils/validationSchemas';
|
import { UserFormData } from '../utils/validationSchemas';
|
||||||
import { formatDate } from '../utils/formatters';
|
|
||||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useFilters } from '../stores/useAppStore';
|
import { useFilters } from '../stores/useAppStore';
|
||||||
|
|
||||||
const Users = () => {
|
const Users = () => {
|
||||||
|
|
@ -20,6 +20,7 @@ const Users = () => {
|
||||||
const itemsPerPage = 5;
|
const itemsPerPage = 5;
|
||||||
|
|
||||||
const { filters, setFilters } = useFilters();
|
const { filters, setFilters } = useFilters();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
|
|
@ -59,7 +60,7 @@ const Users = () => {
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
label: 'تاریخ عضویت',
|
label: 'تاریخ عضویت',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => formatDate(value)
|
render: (value) => new Date(value).toLocaleDateString('fa-IR')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|
@ -176,20 +177,19 @@ const Users = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 space-x-reverse">
|
<div className="flex items-center space-x-4">
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
<Filter className="h-4 w-4 ml-2" />
|
<Filter className="h-4 w-4 ml-2" />
|
||||||
فیلتر
|
فیلتر
|
||||||
</Button>
|
</Button>
|
||||||
<PermissionWrapper permission={25}>
|
<PermissionWrapper permission={25}>
|
||||||
<button
|
<Button
|
||||||
onClick={handleAddUser}
|
onClick={handleAddUser}
|
||||||
disabled={createUserMutation.isPending}
|
disabled={createUserMutation.isPending}
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
|
||||||
title="افزودن کاربر"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-4 w-4 ml-2" />
|
||||||
</button>
|
افزودن کاربر
|
||||||
|
</Button>
|
||||||
</PermissionWrapper>
|
</PermissionWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -211,9 +211,7 @@ const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<LoadingSpinner />
|
||||||
<Table columns={columns} data={[]} loading={true} />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
|
@ -235,7 +233,6 @@ const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
|
||||||
isOpen={showUserModal}
|
isOpen={showUserModal}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
|
|
||||||
import { Button } from '../../../components/ui/Button';
|
|
||||||
import { useAdminUser } from '../core/_hooks';
|
|
||||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
|
||||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
|
||||||
import { formatDate } from '../../../utils/formatters';
|
|
||||||
|
|
||||||
const AdminUserDetailPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id = "" } = useParams();
|
|
||||||
|
|
||||||
const { data: user, isLoading, error } = useAdminUser(id);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="space-y-6 animate-pulse">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
|
||||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="card p-6">
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{[...Array(4)].map((_, j) => (
|
|
||||||
<div key={j}>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{[...Array(2)].map((_, i) => (
|
|
||||||
<div key={i} className="card p-6">
|
|
||||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[...Array(3)].map((_, j) => (
|
|
||||||
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
|
|
||||||
if (!user) return <div>کاربر یافت نشد</div>;
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const isActive = status === 'active';
|
|
||||||
return (
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${isActive
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
|
||||||
}`}>
|
|
||||||
{isActive ? 'فعال' : 'غیرفعال'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin-users')}
|
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<PageTitle>جزئیات کاربر ادمین</PageTitle>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">نمایش اطلاعات کامل کاربر ادمین</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<PermissionWrapper permission={23}>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/admin-users/${id}/edit`)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
ویرایش
|
|
||||||
</Button>
|
|
||||||
</PermissionWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
||||||
<SectionTitle className="flex items-center gap-2 mb-4">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
اطلاعات اصلی
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
نام
|
|
||||||
</label>
|
|
||||||
<BodyText>{user.first_name || 'تعریف نشده'}</BodyText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
نام خانوادگی
|
|
||||||
</label>
|
|
||||||
<BodyText>{user.last_name || 'تعریف نشده'}</BodyText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
نام کاربری
|
|
||||||
</label>
|
|
||||||
<BodyText>{user.username}</BodyText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
وضعیت
|
|
||||||
</label>
|
|
||||||
{getStatusBadge(user.status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user.roles && user.roles.length > 0 && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
||||||
<SectionTitle className="flex items-center gap-2 mb-4">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
نقشها
|
|
||||||
</SectionTitle>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{user.roles.map((role: any) => (
|
|
||||||
<span
|
|
||||||
key={role.id}
|
|
||||||
className="px-3 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full text-sm font-medium"
|
|
||||||
>
|
|
||||||
{role.title}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user.permissions && user.permissions.length > 0 && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
||||||
<SectionTitle className="flex items-center gap-2 mb-4">
|
|
||||||
<Key className="h-5 w-5" />
|
|
||||||
دسترسیهای مستقیم
|
|
||||||
</SectionTitle>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{user.permissions.map((permission: any) => (
|
|
||||||
<div
|
|
||||||
key={permission.id}
|
|
||||||
className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{permission.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{permission.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
||||||
<SectionTitle className="flex items-center gap-2 mb-4">
|
|
||||||
<Calendar className="h-5 w-5" />
|
|
||||||
اطلاعات زمانی
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<SectionSubtitle className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
تاریخ ایجاد
|
|
||||||
</SectionSubtitle>
|
|
||||||
<BodyText>
|
|
||||||
{user.created_at ? formatDate(user.created_at) : 'تعریف نشده'}
|
|
||||||
</BodyText>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<SectionSubtitle className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
||||||
آخرین بروزرسانی
|
|
||||||
</SectionSubtitle>
|
|
||||||
<BodyText>
|
|
||||||
{user.updated_at ? formatDate(user.updated_at) : 'تعریف نشده'}
|
|
||||||
</BodyText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
|
||||||
<SectionTitle className="flex items-center gap-2 mb-4">
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
آمار سریع
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">تعداد نقشها</span>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{user.roles ? user.roles.length : 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">تعداد دسترسیها</span>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{user.permissions ? user.permissions.length : 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminUserDetailPage;
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
import { useAdminUser, useCreateAdminUser, useUpdateAdminUser } from '../core/_hooks';
|
|
||||||
import { AdminUserFormData } from '../core/_models';
|
|
||||||
import { usePermissions } from '../../permissions/core/_hooks';
|
|
||||||
import { useRoles } from '../../roles/core/_hooks';
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
|
||||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
|
||||||
|
|
||||||
const adminUserSchema = yup.object({
|
|
||||||
first_name: yup.string().required('نام الزامی است').min(2, 'نام باید حداقل 2 کاراکتر باشد'),
|
|
||||||
last_name: yup.string().required('نام خانوادگی الزامی است').min(2, 'نام خانوادگی باید حداقل 2 کاراکتر باشد'),
|
|
||||||
username: yup.string().required('نام کاربری الزامی است').min(3, 'نام کاربری باید حداقل 3 کاراکتر باشد'),
|
|
||||||
password: yup.string().when('isEdit', {
|
|
||||||
is: false,
|
|
||||||
then: (schema) => schema.required('رمز عبور الزامی است').min(8, 'رمز عبور باید حداقل 8 کاراکتر باشد'),
|
|
||||||
otherwise: (schema) => schema.notRequired().test('min-length', 'رمز عبور باید حداقل 8 کاراکتر باشد', function (value) {
|
|
||||||
return !value || value.length >= 8;
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
status: yup.string().required('وضعیت الزامی است').oneOf(['active', 'deactive'], 'وضعیت نامعتبر است'),
|
|
||||||
permissions: yup.array().of(yup.number()).default([]),
|
|
||||||
roles: yup.array().of(yup.number()).default([]),
|
|
||||||
isEdit: yup.boolean().default(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
const AdminUserFormPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const isEdit = !!id;
|
|
||||||
|
|
||||||
const { data: user, isLoading: isLoadingUser } = useAdminUser(id || '', isEdit);
|
|
||||||
const { mutate: createUser, isPending: isCreating } = useCreateAdminUser();
|
|
||||||
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser();
|
|
||||||
|
|
||||||
const { data: permissions, isLoading: isLoadingPermissions } = usePermissions();
|
|
||||||
const { data: roles, isLoading: isLoadingRoles } = useRoles();
|
|
||||||
|
|
||||||
const isLoading = isCreating || isUpdating;
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isValid, isDirty },
|
|
||||||
setValue,
|
|
||||||
watch
|
|
||||||
} = useForm<AdminUserFormData>({
|
|
||||||
resolver: yupResolver(adminUserSchema) as any,
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues: {
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
status: 'active' as 'active' | 'deactive',
|
|
||||||
permissions: [],
|
|
||||||
roles: [],
|
|
||||||
isEdit: isEdit
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug form state
|
|
||||||
const formValues = watch();
|
|
||||||
console.log('🔍 Current form values:', formValues);
|
|
||||||
console.log('🔍 Form isValid:', isValid);
|
|
||||||
console.log('🔍 Form isDirty:', isDirty);
|
|
||||||
console.log('🔍 Form errors:', errors);
|
|
||||||
|
|
||||||
// Populate form when editing
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEdit && user) {
|
|
||||||
setValue('first_name', user.first_name, { shouldValidate: true });
|
|
||||||
setValue('last_name', user.last_name, { shouldValidate: true });
|
|
||||||
setValue('username', user.username, { shouldValidate: true });
|
|
||||||
setValue('status', user.status, { shouldValidate: true });
|
|
||||||
setValue('permissions', user.permissions?.map((p: any) => p.id) || [], { shouldValidate: true });
|
|
||||||
setValue('roles', user.roles?.map((r: any) => r.id) || [], { shouldValidate: true });
|
|
||||||
setValue('isEdit', true, { shouldValidate: true });
|
|
||||||
}
|
|
||||||
}, [isEdit, user, setValue]);
|
|
||||||
|
|
||||||
const onSubmit = (data: AdminUserFormData) => {
|
|
||||||
if (isEdit && id) {
|
|
||||||
updateUser({
|
|
||||||
id,
|
|
||||||
userData: {
|
|
||||||
id: parseInt(id),
|
|
||||||
first_name: data.first_name,
|
|
||||||
last_name: data.last_name,
|
|
||||||
username: data.username,
|
|
||||||
password: data.password && data.password.trim() ? data.password : undefined,
|
|
||||||
status: data.status,
|
|
||||||
permissions: data.permissions,
|
|
||||||
roles: data.roles
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
onSuccess: () => {
|
|
||||||
navigate('/admin-users');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('🚀 Creating new admin user...');
|
|
||||||
createUser({
|
|
||||||
first_name: data.first_name,
|
|
||||||
last_name: data.last_name,
|
|
||||||
username: data.username,
|
|
||||||
password: data.password || '',
|
|
||||||
status: data.status,
|
|
||||||
permissions: data.permissions,
|
|
||||||
roles: data.roles
|
|
||||||
}, {
|
|
||||||
onSuccess: (result) => {
|
|
||||||
console.log('✅ Admin user created successfully:', result);
|
|
||||||
console.log('🔄 Navigating to admin users list...');
|
|
||||||
navigate('/admin-users');
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('❌ Error in component onError:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate('/admin-users');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && isLoadingUser) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="space-y-6 animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
|
||||||
<div className="card p-6 space-y-6">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
|
||||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backButton = (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer className="max-w-2xl mx-auto">
|
|
||||||
<FormHeader
|
|
||||||
title={isEdit ? 'ویرایش کاربر ادمین' : 'ایجاد کاربر ادمین جدید'}
|
|
||||||
subtitle={isEdit ? 'ویرایش اطلاعات کاربر ادمین' : 'اطلاعات کاربر ادمین جدید را وارد کنید'}
|
|
||||||
backButton={backButton}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="card p-4 sm:p-6">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 sm:space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Input
|
|
||||||
label="نام"
|
|
||||||
{...register('first_name')}
|
|
||||||
error={errors.first_name?.message}
|
|
||||||
placeholder="نام کاربر"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="نام خانوادگی"
|
|
||||||
{...register('last_name')}
|
|
||||||
error={errors.last_name?.message}
|
|
||||||
placeholder="نام خانوادگی کاربر"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="نام کاربری"
|
|
||||||
{...register('username')}
|
|
||||||
error={errors.username?.message}
|
|
||||||
placeholder="نام کاربری"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label={isEdit ? "رمز عبور (اختیاری)" : "رمز عبور"}
|
|
||||||
type="password"
|
|
||||||
{...register('password')}
|
|
||||||
error={errors.password?.message}
|
|
||||||
placeholder={isEdit ? "رمز عبور جدید (در صورت تمایل به تغییر)" : "رمز عبور"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<MultiSelectAutocomplete
|
|
||||||
label="دسترسیها"
|
|
||||||
options={(permissions || []).map((permission): Option => ({
|
|
||||||
id: permission.id,
|
|
||||||
title: permission.title,
|
|
||||||
description: permission.description
|
|
||||||
}))}
|
|
||||||
selectedValues={watch('permissions') || []}
|
|
||||||
onChange={(values) => setValue('permissions', values, { shouldValidate: true })}
|
|
||||||
placeholder="انتخاب دسترسیها..."
|
|
||||||
isLoading={isLoadingPermissions}
|
|
||||||
error={errors.permissions?.message}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MultiSelectAutocomplete
|
|
||||||
label="نقشها"
|
|
||||||
options={(roles || []).map((role): Option => ({
|
|
||||||
id: role.id,
|
|
||||||
title: role.title,
|
|
||||||
description: role.description
|
|
||||||
}))}
|
|
||||||
selectedValues={watch('roles') || []}
|
|
||||||
onChange={(values) => setValue('roles', values, { shouldValidate: true })}
|
|
||||||
placeholder="انتخاب نقشها..."
|
|
||||||
isLoading={isLoadingRoles}
|
|
||||||
error={errors.roles?.message}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
وضعیت
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
{...register('status')}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="active">فعال</option>
|
|
||||||
<option value="deactive">غیرفعال</option>
|
|
||||||
</select>
|
|
||||||
{errors.status && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.status.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={!isValid || isLoading}
|
|
||||||
>
|
|
||||||
{isEdit ? 'بهروزرسانی' : 'ایجاد'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminUserFormPage;
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAdminUsers, useDeleteAdminUser } from '../core/_hooks';
|
|
||||||
import { AdminUserInfo } from '../core/_models';
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
|
|
||||||
import { Users, UserPlus, Plus } from "lucide-react";
|
|
||||||
import { PageContainer, SectionSubtitle } from '../../../components/ui/Typography';
|
|
||||||
import { TableSkeleton } from '@/components/common/TableSkeleton';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { EmptyState } from '@/components/common/EmptyState';
|
|
||||||
import { ActionButtons } from '@/components/common/ActionButtons';
|
|
||||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
|
||||||
import { formatDate } from '@/utils/formatters';
|
|
||||||
|
|
||||||
const AdminUsersListPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
search: '',
|
|
||||||
status: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: users, isLoading, error } = useAdminUsers(filters);
|
|
||||||
const { mutate: deleteUser, isPending: isDeleting } = useDeleteAdminUser();
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
navigate('/admin-users/create');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleView = (userId: number) => {
|
|
||||||
navigate(`/admin-users/${userId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (userId: number) => {
|
|
||||||
navigate(`/admin-users/${userId}/edit`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (deleteUserId) {
|
|
||||||
deleteUser(deleteUserId, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setDeleteUserId(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFilters(prev => ({ ...prev, search: e.target.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setFilters(prev => ({ ...prev, status: e.target.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری کاربران ادمین</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="مدیریت کاربران ادمین"
|
|
||||||
subtitle="مدیریت کاربران دسترسی به پنل ادمین"
|
|
||||||
icon={Users}
|
|
||||||
actions={
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
|
||||||
title="کاربر ادمین جدید"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<SectionSubtitle>فیلترها</SectionSubtitle>
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
جستجو
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="جستجو در نام، نام خانوادگی یا نام کاربری..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
وضعیت
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.status}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<option value="">همه</option>
|
|
||||||
<option value="active">فعال</option>
|
|
||||||
<option value="deactive">غیرفعال</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton columns={5} rows={5} />
|
|
||||||
) : (users || []).length === 0 ? (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
||||||
<EmptyState
|
|
||||||
icon={Users}
|
|
||||||
title="هیچ کاربر ادمین یافت نشد"
|
|
||||||
description={filters.search || filters.status
|
|
||||||
? "نتیجهای برای جستجوی شما یافت نشد"
|
|
||||||
: "شما هنوز هیچ کاربر ادمین ایجاد نکردهاید"
|
|
||||||
}
|
|
||||||
actionLabel={
|
|
||||||
<>
|
|
||||||
<UserPlus className="h-4 w-4 ml-2" />
|
|
||||||
اولین کاربر ادمین را ایجاد کنید
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onAction={handleCreate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
{/* Desktop Table */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
نام و نام خانوادگی
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
نام کاربری
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
وضعیت
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
تاریخ ایجاد
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
عملیات
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{(users || []).map((user: AdminUserInfo) => (
|
|
||||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{user.username}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
|
||||||
}`}>
|
|
||||||
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{formatDate(user.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<ActionButtons
|
|
||||||
onView={() => handleView(user.id)}
|
|
||||||
onEdit={() => handleEdit(user.id)}
|
|
||||||
onDelete={() => setDeleteUserId(user.id.toString())}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Cards */}
|
|
||||||
<div className="md:hidden p-4 space-y-4">
|
|
||||||
{(users || []).map((user: AdminUserInfo) => (
|
|
||||||
<div key={user.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{user.username}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${user.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
|
||||||
}`}>
|
|
||||||
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
تاریخ ایجاد: {formatDate(user.created_at)}
|
|
||||||
</div>
|
|
||||||
<ActionButtons
|
|
||||||
onView={() => handleView(user.id)}
|
|
||||||
onEdit={() => handleEdit(user.id)}
|
|
||||||
onDelete={() => setDeleteUserId(user.id.toString())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteConfirmModal
|
|
||||||
isOpen={!!deleteUserId}
|
|
||||||
onClose={() => setDeleteUserId(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
title="حذف کاربر ادمین"
|
|
||||||
message="آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
|
||||||
isLoading={isDeleting}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminUsersListPage;
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { QUERY_KEYS } from "@/utils/query-key";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getAdminUsers,
|
|
||||||
getAdminUser,
|
|
||||||
createAdminUser,
|
|
||||||
updateAdminUser,
|
|
||||||
deleteAdminUser,
|
|
||||||
} from "./_requests";
|
|
||||||
import {
|
|
||||||
CreateAdminUserRequest,
|
|
||||||
UpdateAdminUserRequest,
|
|
||||||
AdminUserFilters,
|
|
||||||
} from "./_models";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
export const useAdminUsers = (filters?: AdminUserFilters) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.GET_ADMIN_USERS, filters],
|
|
||||||
queryFn: () => getAdminUsers(filters),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAdminUser = (id: string, enabled: boolean = true) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.GET_ADMIN_USER, id],
|
|
||||||
queryFn: () => getAdminUser(id),
|
|
||||||
enabled: enabled && !!id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCreateAdminUser = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.CREATE_ADMIN_USER],
|
|
||||||
mutationFn: (userData: CreateAdminUserRequest) => createAdminUser(userData),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
|
|
||||||
toast.success("کاربر ادمین با موفقیت ایجاد شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Create admin user error:", error);
|
|
||||||
toast.error(error?.message || "خطا در ایجاد کاربر ادمین");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateAdminUser = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.UPDATE_ADMIN_USER],
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
userData,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
userData: UpdateAdminUserRequest;
|
|
||||||
}) => updateAdminUser(id, userData),
|
|
||||||
onSuccess: (data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [QUERY_KEYS.GET_ADMIN_USER, variables.id],
|
|
||||||
});
|
|
||||||
toast.success("کاربر ادمین با موفقیت بهروزرسانی شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Update admin user error:", error);
|
|
||||||
toast.error(error?.message || "خطا در بهروزرسانی کاربر ادمین");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteAdminUser = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.DELETE_ADMIN_USER],
|
|
||||||
mutationFn: (id: string) => deleteAdminUser(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_USERS] });
|
|
||||||
toast.success("کاربر ادمین با موفقیت حذف شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Delete admin user error:", error);
|
|
||||||
toast.error(error?.message || "خطا در حذف کاربر ادمین");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import {
|
|
||||||
AdminUserInfo,
|
|
||||||
CreateAdminUserRequest,
|
|
||||||
UpdateAdminUserRequest,
|
|
||||||
} from "@/types/auth";
|
|
||||||
|
|
||||||
export interface AdminUserFormData {
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
status: "active" | "deactive";
|
|
||||||
permissions: number[];
|
|
||||||
roles: number[];
|
|
||||||
isEdit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUserFilters {
|
|
||||||
search?: string;
|
|
||||||
status?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersResponse {
|
|
||||||
users: AdminUserInfo[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUserResponse {
|
|
||||||
user: AdminUserInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateAdminUserResponse {
|
|
||||||
user: AdminUserInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateAdminUserResponse {
|
|
||||||
user: AdminUserInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteAdminUserResponse {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export types for easier access
|
|
||||||
export type {
|
|
||||||
AdminUserInfo,
|
|
||||||
CreateAdminUserRequest,
|
|
||||||
UpdateAdminUserRequest,
|
|
||||||
} from "@/types/auth";
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import {
|
|
||||||
httpGetRequest,
|
|
||||||
httpPostRequest,
|
|
||||||
httpPutRequest,
|
|
||||||
httpDeleteRequest,
|
|
||||||
APIUrlGenerator,
|
|
||||||
} from "@/utils/baseHttpService";
|
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
|
||||||
import {
|
|
||||||
AdminUserInfo,
|
|
||||||
CreateAdminUserRequest,
|
|
||||||
UpdateAdminUserRequest,
|
|
||||||
AdminUsersResponse,
|
|
||||||
AdminUserResponse,
|
|
||||||
CreateAdminUserResponse,
|
|
||||||
UpdateAdminUserResponse,
|
|
||||||
DeleteAdminUserResponse,
|
|
||||||
AdminUserFilters,
|
|
||||||
} from "./_models";
|
|
||||||
|
|
||||||
export const getAdminUsers = async (filters?: AdminUserFilters) => {
|
|
||||||
try {
|
|
||||||
const queryParams: Record<string, string | number | null> = {};
|
|
||||||
|
|
||||||
if (filters?.search) queryParams.search = filters.search;
|
|
||||||
if (filters?.status) queryParams.status = filters.status;
|
|
||||||
if (filters?.page) queryParams.page = filters.page;
|
|
||||||
if (filters?.limit) queryParams.limit = filters.limit;
|
|
||||||
|
|
||||||
const url = APIUrlGenerator(API_ROUTES.GET_ADMIN_USERS, queryParams);
|
|
||||||
console.log("🔍 Admin Users URL:", url);
|
|
||||||
console.log("🔍 API_ROUTES.GET_ADMIN_USERS:", API_ROUTES.GET_ADMIN_USERS);
|
|
||||||
|
|
||||||
const response = await httpGetRequest<AdminUsersResponse>(url);
|
|
||||||
|
|
||||||
console.log("Admin Users API Response:", response);
|
|
||||||
console.log("Admin Users data:", response.data);
|
|
||||||
|
|
||||||
// Handle different response structures
|
|
||||||
if (response.data && (response.data as any).admin_users) {
|
|
||||||
return Array.isArray((response.data as any).admin_users)
|
|
||||||
? (response.data as any).admin_users
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data && response.data.users) {
|
|
||||||
return Array.isArray(response.data.users) ? response.data.users : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data && Array.isArray(response.data)) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching admin users:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminUser = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const response = await httpGetRequest<AdminUserResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.GET_ADMIN_USER(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Get Admin User API Response:", response);
|
|
||||||
console.log("Get Admin User data:", response.data);
|
|
||||||
|
|
||||||
if (response.data && (response.data as any).admin_user) {
|
|
||||||
return (response.data as any).admin_user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data && response.data.user) {
|
|
||||||
return response.data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Failed to get admin user");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting admin user:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createAdminUser = async (userData: CreateAdminUserRequest) => {
|
|
||||||
try {
|
|
||||||
console.log("🚀 Creating admin user with data:", userData);
|
|
||||||
|
|
||||||
const response = await httpPostRequest<CreateAdminUserResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.CREATE_ADMIN_USER),
|
|
||||||
userData
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ Create Admin User API Response:", response);
|
|
||||||
console.log("📊 Response data:", response.data);
|
|
||||||
|
|
||||||
if (response.data && (response.data as any).admin_user) {
|
|
||||||
console.log("✅ Returning admin_user from response");
|
|
||||||
return (response.data as any).admin_user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data && response.data.user) {
|
|
||||||
console.log("✅ Returning user from response");
|
|
||||||
return response.data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("⚠️ Response structure unexpected, throwing error");
|
|
||||||
throw new Error("Failed to create admin user");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Error creating admin user:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateAdminUser = async (
|
|
||||||
id: string,
|
|
||||||
userData: UpdateAdminUserRequest
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const response = await httpPutRequest<UpdateAdminUserResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.UPDATE_ADMIN_USER(id)),
|
|
||||||
userData
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Update Admin User API Response:", response);
|
|
||||||
console.log("Update Admin User data:", response.data);
|
|
||||||
|
|
||||||
if (response.data && (response.data as any).admin_user) {
|
|
||||||
return (response.data as any).admin_user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data && response.data.user) {
|
|
||||||
return response.data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Failed to update admin user");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating admin user:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAdminUser = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const response = await httpDeleteRequest<DeleteAdminUserResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.DELETE_ADMIN_USER(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting admin user:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { QUERY_KEYS } from "@/utils/query-key";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { postLogin } from "./_requests";
|
|
||||||
import { LoginRequest, LoginResponse } from "@/types/auth";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
export const useLogin = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.ADMIN_LOGIN],
|
|
||||||
mutationFn: (credentials: LoginRequest) => postLogin(credentials),
|
|
||||||
onSuccess: (response: LoginResponse) => {
|
|
||||||
localStorage.setItem("admin_token", response.tokens.access_token);
|
|
||||||
localStorage.setItem(
|
|
||||||
"admin_refresh_token",
|
|
||||||
response.tokens.refresh_token
|
|
||||||
);
|
|
||||||
localStorage.setItem("admin_user", JSON.stringify(response.admin_user));
|
|
||||||
localStorage.setItem(
|
|
||||||
"admin_permissions",
|
|
||||||
JSON.stringify(response.permissions)
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success("ورود موفقیتآمیز بود");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Login error:", error);
|
|
||||||
toast.error(error?.message || "خطا در ورود");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
|
||||||
import { LoginRequest, LoginResponse } from "@/types/auth";
|
|
||||||
|
|
||||||
export const postLogin = async (credentials: LoginRequest) => {
|
|
||||||
const response = await httpPostRequest<LoginResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.ADMIN_LOGIN),
|
|
||||||
credentials
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const postLogout = () => {
|
|
||||||
localStorage.removeItem("admin_token");
|
|
||||||
localStorage.removeItem("admin_refresh_token");
|
|
||||||
localStorage.removeItem("admin_user");
|
|
||||||
localStorage.removeItem("admin_permissions");
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export const getAuth = async () => {
|
|
||||||
const token = localStorage.getItem("admin_token");
|
|
||||||
const userStr = localStorage.getItem("admin_user");
|
|
||||||
|
|
||||||
if (token && userStr) {
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(userStr);
|
|
||||||
return { token, user };
|
|
||||||
} catch (error) {
|
|
||||||
localStorage.removeItem("admin_token");
|
|
||||||
localStorage.removeItem("admin_refresh_token");
|
|
||||||
localStorage.removeItem("admin_user");
|
|
||||||
localStorage.removeItem("admin_permissions");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useCategories, useDeleteCategory } from '../core/_hooks';
|
|
||||||
import { Category } from '../core/_models';
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { Plus, FolderOpen, Folder } from "lucide-react";
|
|
||||||
import { PageContainer } from "../../../components/ui/Typography";
|
|
||||||
import { PageHeader } from "@/components/layout/PageHeader";
|
|
||||||
import { FiltersSection } from "@/components/common/FiltersSection";
|
|
||||||
import { TableSkeleton } from "@/components/common/TableSkeleton";
|
|
||||||
import { EmptyState } from "@/components/common/EmptyState";
|
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
|
||||||
import { ActionButtons } from "@/components/common/ActionButtons";
|
|
||||||
import { formatDate } from "@/utils/formatters";
|
|
||||||
|
|
||||||
const CategoriesListPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
search: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: categories, isLoading, error } = useCategories(filters);
|
|
||||||
const { mutate: deleteCategory, isPending: isDeleting } = useDeleteCategory();
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
navigate('/categories/create');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (categoryId: number) => {
|
|
||||||
navigate(`/categories/${categoryId}/edit`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (deleteCategoryId) {
|
|
||||||
deleteCategory(deleteCategoryId, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setDeleteCategoryId(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFilters(prev => ({ ...prev, search: e.target.value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری دستهبندیها</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const createButton = (
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
|
||||||
title="دستهبندی جدید"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="مدیریت دستهبندیها"
|
|
||||||
subtitle="مدیریت دستهبندیهای محصولات"
|
|
||||||
icon={FolderOpen}
|
|
||||||
actions={createButton}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FiltersSection isLoading={isLoading} columns={2}>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
جستجو
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="جستجو در نام دستهبندی..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FiltersSection>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton columns={4} rows={5} />
|
|
||||||
) : (!categories || categories.length === 0) ? (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<EmptyState
|
|
||||||
icon={FolderOpen}
|
|
||||||
title="دستهبندیای موجود نیست"
|
|
||||||
description="برای شروع، اولین دستهبندی محصولات خود را ایجاد کنید."
|
|
||||||
actionLabel={
|
|
||||||
<>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
ایجاد دستهبندی جدید
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onAction={handleCreate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
{/* Desktop Table */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
نام دستهبندی
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
توضیحات
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
تاریخ ایجاد
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
||||||
عملیات
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{(categories || []).map((category: Category) => (
|
|
||||||
<tr key={category.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Folder className="h-4 w-4 text-amber-500" />
|
|
||||||
{category.name}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<div className="max-w-xs truncate">
|
|
||||||
{category.description || 'بدون توضیحات'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{formatDate(category.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<ActionButtons
|
|
||||||
onEdit={() => handleEdit(category.id)}
|
|
||||||
onDelete={() => setDeleteCategoryId(category.id.toString())}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Cards */}
|
|
||||||
<div className="md:hidden p-4 space-y-4">
|
|
||||||
{(categories || []).map((category: Category) => (
|
|
||||||
<div key={category.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
||||||
<Folder className="h-4 w-4 text-amber-500" />
|
|
||||||
{category.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{category.description || 'بدون توضیحات'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
تاریخ ایجاد: {formatDate(category.created_at)}
|
|
||||||
</div>
|
|
||||||
<ActionButtons
|
|
||||||
onEdit={() => handleEdit(category.id)}
|
|
||||||
onDelete={() => setDeleteCategoryId(category.id.toString())}
|
|
||||||
showLabels={true}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteConfirmModal
|
|
||||||
isOpen={!!deleteCategoryId}
|
|
||||||
onClose={() => setDeleteCategoryId(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
title="حذف دستهبندی"
|
|
||||||
message="آیا از حذف این دستهبندی اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که در این دستهبندی قرار دارند تأثیر بگذارد."
|
|
||||||
isLoading={isDeleting}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CategoriesListPage;
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { ArrowRight, FolderOpen } from 'lucide-react';
|
|
||||||
import { Button } from '../../../components/ui/Button';
|
|
||||||
import { Input } from '../../../components/ui/Input';
|
|
||||||
import { FileUploader } from '../../../components/ui/FileUploader';
|
|
||||||
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
|
||||||
import { useCategory, useCreateCategory, useUpdateCategory } from '../core/_hooks';
|
|
||||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
|
||||||
|
|
||||||
const CategoryFormPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
const { success: showToast } = useToast();
|
|
||||||
const isEdit = Boolean(id);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
parent_id: null as number | null,
|
|
||||||
file_id: undefined as number | undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [uploadedImage, setUploadedImage] = useState<{ id: string, url: string } | null>(null);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
|
|
||||||
const { data: category, isLoading: isLoadingCategory } = useCategory(
|
|
||||||
id || '0',
|
|
||||||
isEdit
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMutation = useCreateCategory();
|
|
||||||
const updateMutation = useUpdateCategory();
|
|
||||||
const { mutateAsync: uploadFile } = useFileUpload();
|
|
||||||
const { mutate: deleteFile } = useFileDelete();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (category && isEdit) {
|
|
||||||
const fileId = (category as any).file?.id ?? category.file_id;
|
|
||||||
const fileUrl = (category as any).file?.url || '';
|
|
||||||
|
|
||||||
setFormData({
|
|
||||||
name: category.name || '',
|
|
||||||
description: category.description || '',
|
|
||||||
parent_id: (category as any).parent_id || null,
|
|
||||||
file_id: fileId || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileId && fileUrl) {
|
|
||||||
setUploadedImage({ id: String(fileId), url: fileUrl });
|
|
||||||
} else if (fileId) {
|
|
||||||
setUploadedImage({ id: String(fileId), url: '' });
|
|
||||||
} else {
|
|
||||||
setUploadedImage(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [category, isEdit]);
|
|
||||||
|
|
||||||
const handleChange = (field: string, value: any) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
|
||||||
try {
|
|
||||||
const result = await uploadFile(file);
|
|
||||||
const fileId = parseInt(result.id);
|
|
||||||
setUploadedImage({
|
|
||||||
id: result.id,
|
|
||||||
url: result.url
|
|
||||||
});
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
file_id: fileId
|
|
||||||
}));
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileRemove = (fileId: string) => {
|
|
||||||
setUploadedImage(null);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
file_id: undefined
|
|
||||||
}));
|
|
||||||
deleteFile(fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEdit) {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
id: parseInt(id!),
|
|
||||||
...formData
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await createMutation.mutateAsync(formData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving category:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate('/categories');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && isLoadingCategory) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="space-y-6 animate-pulse">
|
|
||||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
|
||||||
<div className="card p-6 space-y-6">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
|
||||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backButton = (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer className="max-w-2xl mx-auto">
|
|
||||||
<FormHeader
|
|
||||||
title={isEdit ? 'ویرایش دستهبندی' : 'ایجاد دستهبندی جدید'}
|
|
||||||
subtitle={isEdit ? 'ویرایش اطلاعات دستهبندی' : 'اطلاعات دستهبندی جدید را وارد کنید'}
|
|
||||||
backButton={backButton}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="card p-4 sm:p-6">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="name">نام دستهبندی</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange('name', e.target.value)}
|
|
||||||
placeholder="نام دستهبندی را وارد کنید"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">توضیحات</Label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => handleChange('description', e.target.value)}
|
|
||||||
placeholder="توضیحات دستهبندی"
|
|
||||||
rows={4}
|
|
||||||
className="input resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FileUploader
|
|
||||||
onUpload={handleFileUpload}
|
|
||||||
onRemove={handleFileRemove}
|
|
||||||
acceptedTypes={['image/*']}
|
|
||||||
maxFileSize={5 * 1024 * 1024}
|
|
||||||
maxFiles={1}
|
|
||||||
mode="single"
|
|
||||||
label="تصویر دستهبندی"
|
|
||||||
description="تصویر دستهبندی را انتخاب کنید (حداکثر 5MB)"
|
|
||||||
onUploadStart={() => setIsUploading(true)}
|
|
||||||
onUploadComplete={() => setIsUploading(false)}
|
|
||||||
initialFiles={uploadedImage ? [{ id: uploadedImage.id, url: uploadedImage.url }] : []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-3 sm:flex-row sm:justify-end sm:space-y-0 sm:space-x-3 sm:space-x-reverse pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
انصراف
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isEdit ? 'ویرایش' : 'ایجاد'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CategoryFormPage;
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { QUERY_KEYS } from "@/utils/query-key";
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
getCategories,
|
|
||||||
getCategory,
|
|
||||||
createCategory,
|
|
||||||
updateCategory,
|
|
||||||
deleteCategory,
|
|
||||||
} from "./_requests";
|
|
||||||
import {
|
|
||||||
CreateCategoryRequest,
|
|
||||||
UpdateCategoryRequest,
|
|
||||||
CategoryFilters,
|
|
||||||
} from "./_models";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
export const useCategories = (filters?: CategoryFilters) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.GET_CATEGORIES, filters],
|
|
||||||
queryFn: () => getCategories(filters),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSearchCategories = (filters: CategoryFilters) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.SEARCH_CATEGORIES, filters],
|
|
||||||
queryFn: () => getCategories(filters),
|
|
||||||
enabled: Object.keys(filters).length > 0,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes for search results
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCategory = (id: string, enabled: boolean = true) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.GET_CATEGORY, id],
|
|
||||||
queryFn: () => getCategory(id),
|
|
||||||
enabled: enabled && !!id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCreateCategory = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.CREATE_CATEGORY],
|
|
||||||
mutationFn: (data: CreateCategoryRequest) => createCategory(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
|
|
||||||
toast.success("دستهبندی با موفقیت ایجاد شد");
|
|
||||||
navigate("/categories");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Create category error:", error);
|
|
||||||
toast.error(error?.message || "خطا در ایجاد دستهبندی");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateCategory = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.UPDATE_CATEGORY],
|
|
||||||
mutationFn: (data: UpdateCategoryRequest) => updateCategory(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [QUERY_KEYS.GET_CATEGORY, variables.id.toString()],
|
|
||||||
});
|
|
||||||
toast.success("دستهبندی با موفقیت ویرایش شد");
|
|
||||||
navigate("/categories");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Update category error:", error);
|
|
||||||
toast.error(error?.message || "خطا در ویرایش دستهبندی");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteCategory = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationKey: [QUERY_KEYS.DELETE_CATEGORY],
|
|
||||||
mutationFn: (id: string) => deleteCategory(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_CATEGORIES] });
|
|
||||||
toast.success("دستهبندی با موفقیت حذف شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Delete category error:", error);
|
|
||||||
toast.error(error?.message || "خطا در حذف دستهبندی");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
export interface Category {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parent_id?: number;
|
|
||||||
file_id?: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryFormData {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
file_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryFilters {
|
|
||||||
search?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCategoryRequest {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
file_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCategoryRequest {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
file_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoriesResponse {
|
|
||||||
categories: Category[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryResponse {
|
|
||||||
category: Category;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCategoryResponse {
|
|
||||||
category: Category;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCategoryResponse {
|
|
||||||
category: Category;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteCategoryResponse {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import {
|
|
||||||
httpGetRequest,
|
|
||||||
httpPostRequest,
|
|
||||||
httpPutRequest,
|
|
||||||
httpDeleteRequest,
|
|
||||||
APIUrlGenerator,
|
|
||||||
} from "@/utils/baseHttpService";
|
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
|
||||||
import {
|
|
||||||
Category,
|
|
||||||
CreateCategoryRequest,
|
|
||||||
UpdateCategoryRequest,
|
|
||||||
CategoriesResponse,
|
|
||||||
CategoryResponse,
|
|
||||||
CreateCategoryResponse,
|
|
||||||
UpdateCategoryResponse,
|
|
||||||
DeleteCategoryResponse,
|
|
||||||
CategoryFilters,
|
|
||||||
} from "./_models";
|
|
||||||
|
|
||||||
export const getCategories = async (filters?: CategoryFilters) => {
|
|
||||||
try {
|
|
||||||
const queryParams: Record<string, string | number | null> = {};
|
|
||||||
|
|
||||||
if (filters?.search) queryParams.search = filters.search;
|
|
||||||
if (filters?.page) queryParams.page = filters.page;
|
|
||||||
if (filters?.limit) queryParams.limit = filters.limit;
|
|
||||||
|
|
||||||
const response = await httpGetRequest<CategoriesResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.GET_CATEGORIES, queryParams, undefined, false)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Categories API Response:", response);
|
|
||||||
|
|
||||||
if (
|
|
||||||
response.data &&
|
|
||||||
response.data.categories &&
|
|
||||||
Array.isArray(response.data.categories)
|
|
||||||
) {
|
|
||||||
return response.data.categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn("Categories is null or not an array:", response.data);
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching categories:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCategory = async (id: string) => {
|
|
||||||
const response = await httpGetRequest<CategoryResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.GET_CATEGORY(id), undefined, undefined, false)
|
|
||||||
);
|
|
||||||
return response.data.category;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCategory = async (data: CreateCategoryRequest) => {
|
|
||||||
const response = await httpPostRequest<CreateCategoryResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.CREATE_CATEGORY, undefined, undefined, false),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data.category;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCategory = async (data: UpdateCategoryRequest) => {
|
|
||||||
const response = await httpPutRequest<UpdateCategoryResponse>(
|
|
||||||
APIUrlGenerator(
|
|
||||||
API_ROUTES.UPDATE_CATEGORY(data.id.toString()),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return response.data.category;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCategory = async (id: string) => {
|
|
||||||
const response = await httpDeleteRequest<DeleteCategoryResponse>(
|
|
||||||
APIUrlGenerator(API_ROUTES.DELETE_CATEGORY(id), undefined, undefined, false)
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
|
||||||
import { PageContainer } from '@/components/ui/Typography';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { Table } from '@/components/ui/Table';
|
|
||||||
import { TableColumn } from '@/types';
|
|
||||||
import { Pagination } from '@/components/ui/Pagination';
|
|
||||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
|
||||||
import { englishToPersian } from '@/utils/numberUtils';
|
|
||||||
import { formatDateTime } from '@/utils/formatters';
|
|
||||||
import { useContactUsMessages, useDeleteContactUsMessage } from '../core/_hooks';
|
|
||||||
import { ContactUsFilters, ContactUsMessage } from '../core/_models';
|
|
||||||
|
|
||||||
const ContactUsListPage: React.FC = () => {
|
|
||||||
const [filters, setFilters] = useState<ContactUsFilters>({
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ContactUsMessage | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useContactUsMessages(filters);
|
|
||||||
const deleteMessageMutation = useDeleteContactUsMessage();
|
|
||||||
|
|
||||||
const messages = data?.messages || [];
|
|
||||||
const total = data?.total ?? messages.length;
|
|
||||||
const limit = filters.limit || 20;
|
|
||||||
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
|
|
||||||
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
offset: (page - 1) * prev.limit,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deleteMessageMutation.mutate(deleteTarget.ID, {
|
|
||||||
onSuccess: () => setDeleteTarget(null),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'id',
|
|
||||||
label: 'شناسه',
|
|
||||||
align: 'center',
|
|
||||||
render: (value: number) => englishToPersian(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: 'نام',
|
|
||||||
align: 'right',
|
|
||||||
render: (value: string) => value || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'phone',
|
|
||||||
label: 'شماره تماس',
|
|
||||||
align: 'left',
|
|
||||||
render: (value: string) => {
|
|
||||||
const display = value ? englishToPersian(value) : '-';
|
|
||||||
return <span dir="ltr">{display}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'message',
|
|
||||||
label: 'پیام',
|
|
||||||
align: 'right',
|
|
||||||
render: (value: string) => {
|
|
||||||
if (!value) return '-';
|
|
||||||
return value.length > 120 ? `${value.slice(0, 120)}...` : value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'created_at',
|
|
||||||
label: 'تاریخ',
|
|
||||||
align: 'right',
|
|
||||||
render: (value: string) => formatDateTime(value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
label: 'عملیات',
|
|
||||||
align: 'center',
|
|
||||||
render: (_val, row: any) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteTarget(row.raw)}
|
|
||||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
|
|
||||||
title="حذف پیام"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tableData = messages.map((message) => ({
|
|
||||||
id: message.ID,
|
|
||||||
name: message.Name || '-',
|
|
||||||
phone: message.PhoneNumber || '-',
|
|
||||||
message: message.Message || '-',
|
|
||||||
created_at: message.CreatedAt,
|
|
||||||
raw: message,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-red-600">خطا در دریافت پیامهای تماس با ما</p>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="پیامهای تماس با ما"
|
|
||||||
subtitle="لیست پیامهای ارسالشده توسط کاربران"
|
|
||||||
icon={MessageSquare}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
{isLoading ? (
|
|
||||||
<Table columns={columns} data={[]} loading={true} />
|
|
||||||
) : messages.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
||||||
پیامی یافت نشد
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
هنوز پیامی برای نمایش وجود ندارد
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table columns={columns} data={tableData} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{messages.length > 0 && totalPages > 1 && (
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
itemsPerPage={limit}
|
|
||||||
totalItems={total}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteConfirmModal
|
|
||||||
isOpen={!!deleteTarget}
|
|
||||||
onClose={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
title="حذف پیام تماس با ما"
|
|
||||||
message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
|
||||||
isLoading={deleteMessageMutation.isPending}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContactUsListPage;
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { QUERY_KEYS } from "@/utils/query-key";
|
|
||||||
import { getContactUsMessages, deleteContactUsMessage } from "./_requests";
|
|
||||||
import { ContactUsFilters } from "./_models";
|
|
||||||
|
|
||||||
export const useContactUsMessages = (filters?: ContactUsFilters) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES, filters],
|
|
||||||
queryFn: () => getContactUsMessages(filters),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteContactUsMessage = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string | number) => deleteContactUsMessage(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES],
|
|
||||||
});
|
|
||||||
toast.success("پیام تماس با ما حذف شد");
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error?.message || "خطا در حذف پیام تماس با ما");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue