Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
# 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
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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.
|
|
@ -0,0 +1,106 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
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", "داشبورد");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
/// <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
/// <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# This would be a test image file
|
||||||
|
# For demo purposes, this represents an image placeholder
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# This is an invalid file format for testing file upload validation
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// ***********************************************
|
||||||
|
// 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";
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// 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 />)
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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.
|
|
@ -0,0 +1,9 @@
|
||||||
|
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,38 +7,55 @@
|
||||||
"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": "^18.2.0",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-apexcharts": "^1.9.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": "^18.2.15",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@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,23 +1,102 @@
|
||||||
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';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: any }) => {
|
// Lazy load pages for better code splitting
|
||||||
const { user } = useAuth();
|
const Login = lazy(() => import('./pages/Login').then(module => ({ default: module.Login })));
|
||||||
|
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 />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,16 +111,94 @@ const AppRoutes = () => {
|
||||||
}>
|
}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<ProductsListPage />} />
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
@ -49,7 +206,9 @@ function App() {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</Suspense>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
@ -58,6 +217,6 @@ function App() {
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
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,44 +1,71 @@
|
||||||
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 { ChartData } from '../../types';
|
import { CardTitle } from '../ui/Typography';
|
||||||
|
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||||
|
|
||||||
|
const formatNumber = (value: number | string) => {
|
||||||
|
const formatted = formatWithThousands(value);
|
||||||
|
return englishToPersian(formatted);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
interface BarChartProps {
|
interface BarChartProps {
|
||||||
data: ChartData[];
|
data: any[];
|
||||||
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-6">
|
<div className="card p-3 sm:p-4 lg:p-6">
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<CardTitle className="mb-3 sm:mb-4">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</CardTitle>
|
||||||
)}
|
)}
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="w-full">
|
||||||
<RechartsBarChart data={data}>
|
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
|
<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: 12 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
|
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: 12 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
|
width={72}
|
||||||
|
tickMargin={8}
|
||||||
|
tickCount={4}
|
||||||
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'var(--tooltip-bg)',
|
backgroundColor: 'var(--toast-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',
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
formatter={(value: any) => formatNumber(value)}
|
||||||
|
labelFormatter={(label: any) => englishToPersian(label)}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
<Bar dataKey="value" fill="url(#barFill)" radius={[8, 8, 0, 0]} barSize={28} />
|
||||||
</RechartsBarChart>
|
</RechartsBarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,44 +1,72 @@
|
||||||
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 { ChartData } from '../../types';
|
import { CardTitle } from '../ui/Typography';
|
||||||
|
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||||
|
|
||||||
|
const formatNumber = (value: number | string) => {
|
||||||
|
const formatted = formatWithThousands(value);
|
||||||
|
return englishToPersian(formatted);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
interface LineChartProps {
|
interface LineChartProps {
|
||||||
data: ChartData[];
|
data: any[];
|
||||||
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-6">
|
<div className="card p-3 sm:p-4 lg:p-6">
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<CardTitle className="mb-3 sm:mb-4">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</CardTitle>
|
||||||
)}
|
)}
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="w-full">
|
||||||
<RechartsLineChart data={data}>
|
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
|
<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: 12 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
|
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: 12 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
|
width={72}
|
||||||
|
tickMargin={8}
|
||||||
|
tickCount={4}
|
||||||
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'var(--tooltip-bg)',
|
backgroundColor: 'var(--toast-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',
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
formatter={(value: any) => formatNumber(value)}
|
||||||
|
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,8 +1,9 @@
|
||||||
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { ChartData } from '../../types';
|
import { CardTitle } from '../ui/Typography';
|
||||||
|
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||||
|
|
||||||
interface PieChartProps {
|
interface PieChartProps {
|
||||||
data: ChartData[];
|
data: any[];
|
||||||
title?: string;
|
title?: string;
|
||||||
colors?: string[];
|
colors?: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -10,40 +11,81 @@ 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="card p-6">
|
<div className="flex flex-col gap-2">
|
||||||
|
{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 && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<CardTitle className="mb-3 sm:mb-4 text-center">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</CardTitle>
|
||||||
)}
|
)}
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="w-full flex items-center gap-4">
|
||||||
|
{/* 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={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
label={false}
|
||||||
outerRadius={80}
|
outerRadius="75%"
|
||||||
|
innerRadius="35%"
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth={3}
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((_, 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: 'var(--tooltip-bg)',
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
border: 'none',
|
color: '#1f2937',
|
||||||
|
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',
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]}
|
||||||
/>
|
/>
|
||||||
</RechartsPieChart>
|
</RechartsPieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
|
||||||
|
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: Error): State {
|
static getDerivedStateFromError(_: Error): State {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
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,4 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -27,31 +28,31 @@ export const StatsCard = ({
|
||||||
const isNegative = change && change < 0;
|
const isNegative = change && change < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 animate-fade-in">
|
<div className="card p-4 sm:p-5 lg: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 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}>
|
||||||
<Icon className="h-6 w-6 text-white" />
|
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-5 w-0 flex-1">
|
<div className="mr-3 sm:mr-5 w-0 flex-1 min-w-0">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
<StatLabel className="truncate">
|
||||||
{title}
|
{title}
|
||||||
</dt>
|
</StatLabel>
|
||||||
<dd className="flex items-baseline">
|
<dd className="flex items-baseline">
|
||||||
<div className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<StatValue className="truncate">
|
||||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
</div>
|
</StatValue>
|
||||||
{change !== undefined && (
|
{change !== undefined && (
|
||||||
<div className={`mr-2 flex items-baseline text-sm font-semibold ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-gray-500'
|
<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'
|
||||||
}`}>
|
}`}>
|
||||||
{isPositive && <TrendingUp className="h-4 w-4 flex-shrink-0 self-center ml-1" />}
|
{isPositive && <TrendingUp 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" />}
|
{isNegative && <TrendingDown className="h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{isPositive ? 'افزایش' : 'کاهش'}
|
{isPositive ? 'افزایش' : 'کاهش'}
|
||||||
</span>
|
</span>
|
||||||
{Math.abs(change)}%
|
<span className="truncate">{Math.abs(change)}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
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,126 +1,82 @@
|
||||||
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 { User, Phone, Mail, UserCircle } from 'lucide-react';
|
import * as yup from 'yup';
|
||||||
import { Input } from '../ui/Input';
|
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { userSchema, UserFormData } from '../../utils/validationSchemas';
|
import { Input } from '../ui/Input';
|
||||||
|
import { 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;
|
||||||
onCancel: () => void;
|
defaultValues?: Partial<UserFormData>;
|
||||||
|
initialData?: any;
|
||||||
|
onCancel?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserForm = ({
|
export const UserForm = ({ onSubmit, defaultValues, initialData, onCancel, loading, isEdit, isLoading }: UserFormProps) => {
|
||||||
initialData,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
loading = false,
|
|
||||||
isEdit = false
|
|
||||||
}: UserFormProps) => {
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isValid },
|
formState: { errors, isValid }
|
||||||
} = useForm<UserFormData>({
|
} = useForm({
|
||||||
resolver: yupResolver(userSchema),
|
resolver: yupResolver(userSchema),
|
||||||
mode: 'onChange',
|
defaultValues: defaultValues || initialData,
|
||||||
defaultValues: initialData,
|
mode: 'onChange'
|
||||||
});
|
}) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-6">
|
<div className="space-y-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)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit as any)} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Input
|
<Input
|
||||||
label="نام و نام خانوادگی"
|
label="نام"
|
||||||
placeholder="علی احمدی"
|
|
||||||
icon={User}
|
|
||||||
error={errors.name?.message}
|
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
|
error={errors.name?.message}
|
||||||
|
placeholder="نام کاربر"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="ایمیل"
|
label="ایمیل"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ali@example.com"
|
|
||||||
icon={Mail}
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
|
error={errors.email?.message}
|
||||||
|
placeholder="example@email.com"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="شماره تلفن"
|
label="تلفن"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="09123456789"
|
|
||||||
icon={Phone}
|
|
||||||
error={errors.phone?.message}
|
|
||||||
{...register('phone')}
|
{...register('phone')}
|
||||||
|
error={errors.phone?.message}
|
||||||
|
placeholder="09xxxxxxxxx"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="pt-4">
|
||||||
<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"
|
||||||
loading={loading}
|
disabled={!isValid || isLoading}
|
||||||
disabled={!isValid}
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isEdit ? 'ویرایش' : 'افزودن'}
|
{isLoading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
</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 { Button } from '../ui/Button';
|
import { SectionTitle } from '../ui/Typography';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
|
|
@ -14,21 +14,19 @@ 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-sm border-b border-gray-200 dark:border-gray-700">
|
<header className="bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center space-x-4 space-x-reverse">
|
||||||
<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>
|
||||||
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<SectionTitle>خوش آمدید</SectionTitle>
|
||||||
خوش آمدید
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-2 space-x-reverse">
|
||||||
<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"
|
||||||
|
|
@ -48,15 +46,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 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<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?.name?.charAt(0) || 'A'}
|
{user?.first_name?.charAt(0) || 'A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden md:block">
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:block">
|
||||||
{user?.name || 'کاربر'}
|
{user?.first_name} {user?.last_name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -65,10 +63,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?.name}
|
{user?.first_name} {user?.last_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?.email}
|
{user?.username}
|
||||||
</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,23 +1,46 @@
|
||||||
import { useState } from 'react';
|
import { Suspense, 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">
|
<div className="flex h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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,62 +1,186 @@
|
||||||
import { useState } from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Home,
|
||||||
Users,
|
Settings,
|
||||||
ShoppingBag,
|
Shield,
|
||||||
|
UserCog,
|
||||||
|
Key,
|
||||||
|
LogOut,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
Package,
|
||||||
|
FolderOpen,
|
||||||
|
Sliders,
|
||||||
|
BadgePercent,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
FileText,
|
Users,
|
||||||
Bell,
|
Truck,
|
||||||
Menu,
|
|
||||||
X,
|
X,
|
||||||
ChevronDown
|
MessageSquare,
|
||||||
|
CreditCard,
|
||||||
|
Wallet,
|
||||||
|
BarChart3,
|
||||||
|
FileText,
|
||||||
|
TrendingUp
|
||||||
} 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 { MenuItem } from '../../types';
|
import { SectionTitle, SmallText } from '../ui/Typography';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
icon: any;
|
||||||
|
path?: string;
|
||||||
|
permission?: number;
|
||||||
|
children?: MenuItem[];
|
||||||
|
exact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: 'dashboard',
|
title: 'داشبورد',
|
||||||
label: 'داشبورد',
|
icon: Home,
|
||||||
icon: LayoutDashboard,
|
|
||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'users',
|
title: 'سفارشات',
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reports',
|
title: 'مدیریت کاربران',
|
||||||
label: 'گزارشها',
|
icon: Users,
|
||||||
|
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: '/reports',
|
path: '/contact-us',
|
||||||
permission: 25,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notifications',
|
title: 'مدیریت محصولات',
|
||||||
label: 'اعلانات',
|
icon: Package,
|
||||||
icon: Bell,
|
children: [
|
||||||
path: '/notifications',
|
{
|
||||||
permission: 30,
|
title: 'محصولات',
|
||||||
|
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 {
|
||||||
|
|
@ -65,98 +189,129 @@ interface SidebarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
const { user } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
const location = useLocation();
|
||||||
|
const [expandedItems, setExpandedItems] = React.useState<string[]>(() => {
|
||||||
|
// Load from localStorage on mount
|
||||||
|
const saved = localStorage.getItem('sidebar_expanded_items');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
});
|
||||||
|
|
||||||
const toggleExpanded = (itemId: string) => {
|
// Auto-expand menu items based on current route
|
||||||
setExpandedItems(prev =>
|
React.useEffect(() => {
|
||||||
prev.includes(itemId)
|
const currentPath = location.pathname;
|
||||||
? prev.filter(id => id !== itemId)
|
|
||||||
: [...prev, itemId]
|
setExpandedItems(prev => {
|
||||||
);
|
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) => {
|
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.includes(item.id);
|
const isExpanded = expandedItems.includes(item.title);
|
||||||
|
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={child.path}
|
to={item.path!}
|
||||||
|
end={item.exact}
|
||||||
|
onClick={() => {
|
||||||
|
// Close mobile menu when clicking a link
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={({ isActive }) =>
|
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' : ''
|
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive
|
||||||
|
? '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'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
onClick={onClose}
|
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||||
>
|
>
|
||||||
{child.label}
|
<item.icon className="ml-3 h-5 w-5" />
|
||||||
|
{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 (!hasChildren) {
|
if (item.permission) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<PermissionWrapper key={item.title} permission={item.permission}>
|
||||||
to={item.path}
|
{menuContent}
|
||||||
className={({ isActive }) =>
|
</PermissionWrapper>
|
||||||
`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>{menuContent}</div>;
|
return <div key={item.title}>{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"
|
||||||
|
|
@ -164,58 +319,62 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
<div className={`
|
<div className={`
|
||||||
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
|
fixed lg:static inset-y-0 right-0 z-50
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
w-64 transform transition-transform duration-300 ease-in-out
|
||||||
lg:relative lg:translate-x-0
|
lg:translate-x-0 lg:block
|
||||||
|
${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
|
||||||
`}>
|
`}>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
{/* Mobile close button */}
|
||||||
<div className="flex items-center">
|
<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="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
<SectionTitle>
|
||||||
<LayoutDashboard className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="mr-3 text-xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
پنل مدیریت
|
پنل مدیریت
|
||||||
</span>
|
</SectionTitle>
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 lg:hidden"
|
className="p-2 rounded-xl 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-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
{/* Logo - desktop only */}
|
||||||
<div className="flex items-center mb-6 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<div className="w-10 h-10 bg-primary-600 rounded-full flex items-center justify-center">
|
<SectionTitle>
|
||||||
<span className="text-white font-medium">
|
پنل مدیریت
|
||||||
{user?.name?.charAt(0) || 'A'}
|
</SectionTitle>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-2">
|
{/* Navigation */}
|
||||||
{menuItems.map(item => (
|
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
|
||||||
<div key={item.id}>
|
{menuItems.map(item => renderMenuItem(item))}
|
||||||
{item.permission ? (
|
|
||||||
<PermissionWrapper permission={item.permission}>
|
|
||||||
{renderMenuItem(item)}
|
|
||||||
</PermissionWrapper>
|
|
||||||
) : (
|
|
||||||
renderMenuItem(item)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
</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>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<SmallText>
|
||||||
|
{user?.first_name} {user?.last_name}
|
||||||
|
</SmallText>
|
||||||
|
<SmallText>
|
||||||
|
{user?.username}
|
||||||
|
</SmallText>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { MouseEvent, ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick'> {
|
||||||
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?: () => void;
|
onClick?: (e?: MouseEvent<HTMLButtonElement>) => void;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: 'button' | 'submit' | 'reset';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -20,8 +21,9 @@ export const Button = ({
|
||||||
onClick,
|
onClick,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
className = '',
|
className = '',
|
||||||
|
...rest
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
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 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 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',
|
||||||
|
|
@ -52,6 +54,7 @@ export const Button = ({
|
||||||
disabledClasses,
|
disabledClasses,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
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,54 +1,88 @@
|
||||||
import { forwardRef } from 'react';
|
import React from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { Label } from './Typography';
|
||||||
|
import { persianToEnglish, formatWithThousands } from '../../utils/numberUtils';
|
||||||
|
|
||||||
interface InputProps {
|
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
type?: string;
|
helperText?: string;
|
||||||
placeholder?: string;
|
inputSize?: 'sm' | 'md' | 'lg';
|
||||||
className?: string;
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
icon?: any;
|
numeric?: boolean;
|
||||||
disabled?: boolean;
|
thousandSeparator?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
|
({ label, error, helperText, inputSize = 'md', className, id, onChange, type, numeric, thousandSeparator, ...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 && <Label htmlFor={id}>{label}</Label>}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<input {...inputProps} />
|
||||||
{label}
|
{helperText && !error && (
|
||||||
</label>
|
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
|
||||||
)}
|
)}
|
||||||
<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-sm text-red-600 dark:text-red-400">
|
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
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,12 +1,15 @@
|
||||||
import { useEffect } from 'react';
|
import React, { 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: any;
|
children: React.ReactNode;
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = ({
|
export const Modal = ({
|
||||||
|
|
@ -14,7 +17,9 @@ 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) => {
|
||||||
|
|
@ -40,7 +45,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 (
|
||||||
|
|
@ -52,26 +57,31 @@ export const Modal = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={`
|
<div className={`
|
||||||
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
|
relative w-full ${sizeClasses[size]}
|
||||||
${sizeClasses[size]} transform transition-all
|
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
|
||||||
|
transform transition-all border border-gray-200 dark:border-gray-700
|
||||||
`}>
|
`}>
|
||||||
{title && (
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
<SectionSubtitle>{title}</SectionSubtitle>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
{showCloseButton && (
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-4 sm: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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
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-hidden">
|
<div className="hidden md:block card overflow-x-auto">
|
||||||
<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,15 +83,22 @@ 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-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
|
'px-6 py-3 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="flex items-center justify-end space-x-1">
|
<div className={clsx('flex items-center space-x-1',
|
||||||
<span>{column.label}</span>
|
column.align === 'left' && 'justify-start',
|
||||||
|
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">
|
<div className="flex flex-col ml-1">
|
||||||
<ChevronUp
|
<ChevronUp
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-3 w-3',
|
'h-3 w-3',
|
||||||
|
|
@ -119,7 +126,12 @@ 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="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right">
|
<td key={column.key} className={clsx(
|
||||||
|
'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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,696 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const pageSize = 10;
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
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,41 +1,58 @@
|
||||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
import { AuthState, User } from '../types';
|
import { AuthState, AdminUser, Permission } from '../types/auth';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
interface AuthContextType {
|
||||||
login: (email: string, password: string) => Promise<boolean>;
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
user: AdminUser | null;
|
||||||
|
permissions: Permission[];
|
||||||
|
allPermissions: Permission[];
|
||||||
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
hasPermission: (permission: number) => boolean;
|
restoreSession: () => void;
|
||||||
|
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_SUCCESS'; payload: { user: User; token: string } }
|
| { type: 'LOGIN'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: string } }
|
||||||
| { type: 'LOGOUT' }
|
| { type: 'LOGOUT' }
|
||||||
| { type: 'RESTORE_SESSION'; payload: { user: User; token: string } };
|
| { type: 'RESTORE_SESSION'; payload: { user: AdminUser; permissions: Permission[]; allPermissions: Permission[]; token: string; refreshToken: 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_SUCCESS':
|
case 'LOGIN':
|
||||||
|
case 'RESTORE_SESSION':
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
user: action.payload.user,
|
user: action.payload.user,
|
||||||
permissions: action.payload.user.permissions,
|
permissions: action.payload.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 'RESTORE_SESSION':
|
case 'SET_LOADING':
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
...state,
|
||||||
user: action.payload.user,
|
isLoading: action.payload,
|
||||||
permissions: action.payload.user.permissions,
|
|
||||||
token: action.payload.token,
|
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
@ -44,79 +61,90 @@ 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);
|
||||||
|
|
||||||
useEffect(() => {
|
const restoreSession = () => {
|
||||||
const token = localStorage.getItem('admin_token');
|
dispatch({ type: 'SET_LOADING', payload: true });
|
||||||
const userStr = localStorage.getItem('admin_user');
|
|
||||||
|
|
||||||
if (token && userStr) {
|
const token = localStorage.getItem('admin_token');
|
||||||
|
const refreshToken = localStorage.getItem('admin_refresh_token');
|
||||||
|
const userStr = localStorage.getItem('admin_user');
|
||||||
|
const permissionsStr = localStorage.getItem('admin_permissions');
|
||||||
|
|
||||||
|
if (token && userStr && permissionsStr) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(userStr);
|
const user = JSON.parse(userStr);
|
||||||
dispatch({ type: 'RESTORE_SESSION', payload: { user, token } });
|
const permissions = JSON.parse(permissionsStr);
|
||||||
} catch (error) {
|
|
||||||
localStorage.removeItem('admin_token');
|
|
||||||
localStorage.removeItem('admin_user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockToken = 'mock-jwt-token-' + Date.now();
|
|
||||||
|
|
||||||
if (email === 'admin@test.com' && password === 'admin123') {
|
|
||||||
localStorage.setItem('admin_token', mockToken);
|
|
||||||
localStorage.setItem('admin_user', JSON.stringify(mockUser));
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'LOGIN_SUCCESS',
|
type: 'RESTORE_SESSION',
|
||||||
payload: { user: mockUser, token: mockToken }
|
payload: {
|
||||||
});
|
user,
|
||||||
|
permissions,
|
||||||
return true;
|
allPermissions: permissions,
|
||||||
|
token,
|
||||||
|
refreshToken: refreshToken || ''
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
localStorage.removeItem('admin_token');
|
||||||
return false;
|
localStorage.removeItem('admin_refresh_token');
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
localStorage.removeItem('admin_permissions');
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restoreSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
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 = (permission: number): boolean => {
|
const hasPermission = (permissionId: number): boolean => {
|
||||||
return state.permissions.includes(permission);
|
// اگر Super Admin (id=1) باشد، به همه چیز دسترسی دارد
|
||||||
|
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,13 +12,14 @@ 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 || (prefersDark ? 'dark' : 'light');
|
const initialTheme = savedTheme || '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');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
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: (response) => {
|
onSuccess: () => {
|
||||||
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: (response, variables) => {
|
onSuccess: (_, 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,16 +30,36 @@
|
||||||
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-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
@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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
@ -53,4 +73,76 @@
|
||||||
.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,27 +1,17 @@
|
||||||
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: {
|
||||||
retry: (failureCount, error: any) => {
|
gcTime: 0,
|
||||||
if (error?.response?.status === 404) return false;
|
staleTime: 0,
|
||||||
if (error?.response?.status === 403) return false;
|
refetchOnMount: true,
|
||||||
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: {
|
||||||
onError: (error: any) => {
|
retry: 1,
|
||||||
const message =
|
|
||||||
error?.response?.data?.message ||
|
|
||||||
error?.message ||
|
|
||||||
"خطایی رخ داده است";
|
|
||||||
toast.error(message);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,135 @@
|
||||||
import { Users, ShoppingBag, DollarSign, TrendingUp } from 'lucide-react';
|
import { ApexAreaChartCard } from '../components/charts/ApexAreaChartCard';
|
||||||
import { StatsCard } from '../components/dashboard/StatsCard';
|
import { ApexBarChartCard } from '../components/charts/ApexBarChartCard';
|
||||||
import { BarChart } from '../components/charts/BarChart';
|
import { useMemo } from 'react';
|
||||||
import { LineChart } from '../components/charts/LineChart';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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 { PermissionWrapper } from '../components/common/PermissionWrapper';
|
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
||||||
import { ChartData, TableColumn } from '../types';
|
import { ChartData, TableColumn } from '../types';
|
||||||
|
|
||||||
const statsData = [
|
export const Dashboard = () => {
|
||||||
|
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[] = [
|
||||||
{
|
{
|
||||||
title: 'کل کاربران',
|
key: 'order_number',
|
||||||
value: 1247,
|
label: 'شماره سفارش',
|
||||||
change: 12,
|
render: (value: string) => `#${value}`,
|
||||||
icon: Users,
|
|
||||||
color: 'blue',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'فروش ماهانه',
|
key: 'customer',
|
||||||
value: '۲۴,۵۶۷,۰۰۰',
|
label: 'مشتری',
|
||||||
change: 8.5,
|
render: (_value, row: any) => {
|
||||||
icon: DollarSign,
|
const customer = row.user || row.customer;
|
||||||
color: 'green',
|
const name = `${customer?.first_name || ''} ${customer?.last_name || ''}`.trim();
|
||||||
|
return name || 'نامشخص';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'کل سفارشات',
|
key: 'final_total',
|
||||||
value: 356,
|
label: 'مبلغ',
|
||||||
change: -2.3,
|
render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0),
|
||||||
icon: ShoppingBag,
|
|
||||||
color: 'yellow',
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'رشد فروش',
|
|
||||||
value: '۲۳.۵%',
|
|
||||||
change: 15.2,
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: 'purple',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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',
|
key: 'status',
|
||||||
label: 'وضعیت',
|
label: 'وضعیت',
|
||||||
render: (value) => (
|
render: (value: any) => <StatusBadge status={value} type="order" />,
|
||||||
<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',
|
key: 'created_at',
|
||||||
label: 'عملیات',
|
label: 'تاریخ',
|
||||||
render: (_, row) => (
|
render: (value: string) => formatDate(value),
|
||||||
<div className="flex space-x-2">
|
},
|
||||||
<Button size="sm" variant="secondary">
|
];
|
||||||
ویرایش
|
|
||||||
</Button>
|
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
|
||||||
<PermissionWrapper permission={22}>
|
|
||||||
<Button size="sm" variant="danger">
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</PermissionWrapper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
<div className="flex items-center justify-between">
|
{/* Header with mobile-responsive layout */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
داشبورد
|
<PageTitle>داشبورد</PageTitle>
|
||||||
</h1>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Button variant="secondary">
|
|
||||||
گزارشگیری
|
|
||||||
</Button>
|
|
||||||
<PermissionWrapper permission={25}>
|
|
||||||
<Button>
|
|
||||||
اضافه کردن
|
|
||||||
</Button>
|
|
||||||
</PermissionWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
{/* Charts - Better mobile layout */}
|
||||||
{statsData.map((stat, index) => (
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<StatsCard key={index} {...stat} />
|
<div className="min-w-0">
|
||||||
))}
|
<ApexBarChartCard
|
||||||
</div>
|
data={monthlySalesData}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<BarChart
|
|
||||||
data={chartData}
|
|
||||||
title="فروش ماهانه"
|
title="فروش ماهانه"
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
/>
|
/>
|
||||||
<LineChart
|
</div>
|
||||||
data={chartData}
|
<div className="min-w-0">
|
||||||
title="روند رشد"
|
<ApexAreaChartCard
|
||||||
|
data={registrationGrowthData}
|
||||||
|
title="روند رشد ثبتنام کاربران"
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* Table and Pie Chart - Mobile responsive */}
|
||||||
<div className="lg:col-span-2">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||||
<div className="card p-6">
|
<div className="xl:col-span-2 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<div className="card p-3 sm:p-4 lg:p-6">
|
||||||
کاربران اخیر
|
<CardTitle className="mb-3 sm:mb-4">
|
||||||
</h3>
|
آخرین سفارشات در انتظار
|
||||||
<Table
|
</CardTitle>
|
||||||
columns={userColumns}
|
<div className="overflow-x-auto">
|
||||||
data={recentUsers}
|
<Table columns={orderColumns} data={ordersTableData} loading={isOrdersLoading} />
|
||||||
/>
|
</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={pieData}
|
data={salesByCategoryData}
|
||||||
title="دستگاههای کاربری"
|
title="توزیع فروش بر اساس دستهبندی"
|
||||||
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, Lock, Mail } from 'lucide-react';
|
import { Eye, EyeOff, Lock, User } 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, login } = useAuth();
|
const { isAuthenticated, isLoading, restoreSession } = 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,
|
||||||
|
|
@ -23,24 +26,33 @@ 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('');
|
||||||
|
|
||||||
try {
|
login(data, {
|
||||||
const success = await login(data.email, data.password);
|
onSuccess: () => {
|
||||||
if (!success) {
|
restoreSession();
|
||||||
setError('ایمیل یا رمز عبور اشتباه است');
|
navigate('/');
|
||||||
}
|
},
|
||||||
} catch (error) {
|
onError: () => {
|
||||||
setError('خطایی رخ داده است. لطفا دوباره تلاش کنید');
|
setError('نام کاربری یا رمز عبور اشتباه است');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,12 +73,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="email"
|
type="text"
|
||||||
placeholder="admin@test.com"
|
placeholder="نام کاربری خود را وارد کنید"
|
||||||
icon={Mail}
|
icon={User}
|
||||||
error={errors.email?.message}
|
error={errors.username?.message}
|
||||||
{...register('email')}
|
{...register('username')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -79,7 +91,7 @@ export const Login = () => {
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="admin123"
|
placeholder="رمز عبور خود را وارد کنید"
|
||||||
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')}
|
||||||
|
|
@ -110,15 +122,9 @@ 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={loading}
|
loading={isLoggingIn}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Bell, Check, X, Plus, Search, Filter, AlertCircle, Info, CheckCircle, XCircle } from 'lucide-react';
|
import { Plus, Search, Filter, Bell, BellOff, Clock, Eye } 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 = [
|
||||||
{
|
{
|
||||||
|
|
@ -96,13 +100,13 @@ export const Notifications = () => {
|
||||||
const getNotificationIcon = (type: string) => {
|
const getNotificationIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return <XCircle className="h-5 w-5 text-red-600" />;
|
return <BellOff className="h-5 w-5 text-red-600" />;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
|
return <Bell className="h-5 w-5 text-yellow-600" />;
|
||||||
case 'success':
|
case 'success':
|
||||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
return <Bell className="h-5 w-5 text-green-600" />;
|
||||||
case 'info':
|
case 'info':
|
||||||
return <Info className="h-5 w-5 text-blue-600" />;
|
return <Eye 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" />;
|
||||||
}
|
}
|
||||||
|
|
@ -156,16 +160,11 @@ export const Notifications = () => {
|
||||||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<PageTitle>اعلانات</PageTitle>
|
||||||
<div>
|
<StatValue>
|
||||||
<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} اعلان
|
||||||
</p>
|
</StatValue>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -173,7 +172,7 @@ export const Notifications = () => {
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={handleMarkAllAsRead}
|
||||||
disabled={unreadCount === 0}
|
disabled={unreadCount === 0}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 ml-2" />
|
<BellOff className="h-4 w-4 ml-2" />
|
||||||
همه را خوانده شده علامت بزن
|
همه را خوانده شده علامت بزن
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button>
|
||||||
|
|
@ -181,7 +180,6 @@ 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">
|
||||||
|
|
@ -189,41 +187,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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
|
<StatValue>{notifications.length}</StatValue>
|
||||||
</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">
|
||||||
<AlertCircle className="h-8 w-8 text-red-600" />
|
<BellOff 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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
|
<StatValue>{unreadCount}</StatValue>
|
||||||
</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">
|
||||||
<XCircle className="h-8 w-8 text-red-600" />
|
<BellOff 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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<StatValue>
|
||||||
{notifications.filter(n => n.type === 'error').length}
|
{notifications.filter(n => n.type === 'error').length}
|
||||||
</p>
|
</StatValue>
|
||||||
</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">
|
||||||
<AlertCircle className="h-8 w-8 text-yellow-600" />
|
<Bell 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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<StatValue>
|
||||||
{notifications.filter(n => n.type === 'warning').length}
|
{notifications.filter(n => n.type === 'warning').length}
|
||||||
</p>
|
</StatValue>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,7 +306,7 @@ export const Notifications = () => {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<BellOff className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -316,7 +314,7 @@ export const Notifications = () => {
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => handleDeleteNotification(notification.id)}
|
onClick={() => handleDeleteNotification(notification.id)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<BellOff className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,6 +337,6 @@ export const Notifications = () => {
|
||||||
totalItems={filteredNotifications.length}
|
totalItems={filteredNotifications.length}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
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,10 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Search, Filter, Package } from 'lucide-react';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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: '۱۴۰۲/۰۸/۱۵' },
|
||||||
|
|
@ -19,7 +21,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -104,16 +105,11 @@ const Products = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<PageTitle>مدیریت محصولات</PageTitle>
|
||||||
<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">
|
||||||
|
|
@ -127,7 +123,6 @@ 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">
|
||||||
|
|
@ -135,7 +130,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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
|
<StatValue>{allProducts.length}</StatValue>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,9 +140,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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<StatValue>
|
||||||
{allProducts.filter(p => p.status === 'موجود').length}
|
{allProducts.filter(p => p.status === 'موجود').length}
|
||||||
</p>
|
</StatValue>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,9 +152,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>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<StatValue>
|
||||||
{allProducts.filter(p => p.status === 'ناموجود').length}
|
{allProducts.filter(p => p.status === 'ناموجود').length}
|
||||||
</p>
|
</StatValue>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +180,7 @@ const Products = () => {
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={paginatedProducts}
|
data={paginatedProducts}
|
||||||
loading={loading}
|
loading={false}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|
@ -196,7 +191,7 @@ const Products = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Download, Calendar, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
import { FileText, Download, 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 { LineChart } from '../components/charts/LineChart';
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
@ -164,7 +166,9 @@ 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,6 +4,7 @@ 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 = () => {
|
||||||
|
|
@ -43,15 +44,11 @@ export const Settings = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
|
title="تنظیمات سیستم"
|
||||||
<SettingsIcon className="h-6 w-6 ml-3" />
|
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
|
||||||
تنظیمات سیستم
|
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 } from 'lucide-react';
|
import { Plus, Search, Filter, Users as UsersIcon, UserCheck, UserX } 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,6 +8,7 @@ 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: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||||
|
|
@ -26,7 +27,6 @@ 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,27 +112,28 @@ export const Users = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<PageContainer>
|
||||||
<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>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<PageTitle>مدیریت کاربران</PageTitle>
|
||||||
مدیریت کاربران
|
|
||||||
</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-4">
|
<div className="flex items-center space-x-3 space-x-reverse">
|
||||||
<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 onClick={handleAddUser}>
|
<button
|
||||||
<Plus className="h-4 w-4 ml-2" />
|
onClick={handleAddUser}
|
||||||
افزودن کاربر
|
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"
|
||||||
</Button>
|
title="افزودن کاربر"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</PermissionWrapper>
|
</PermissionWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,7 +158,7 @@ export const Users = () => {
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={paginatedUsers}
|
data={paginatedUsers}
|
||||||
loading={loading}
|
loading={false}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|
@ -170,6 +171,7 @@ export const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
||||||
isOpen={showUserModal}
|
isOpen={showUserModal}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -178,10 +180,10 @@ export const Users = () => {
|
||||||
initialData={editingUser}
|
initialData={editingUser}
|
||||||
onSubmit={handleSubmitUser}
|
onSubmit={handleSubmitUser}
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
loading={loading}
|
loading={false}
|
||||||
isEdit={!!editingUser}
|
isEdit={!!editingUser}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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,7 +20,6 @@ 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,
|
||||||
|
|
@ -60,7 +59,7 @@ const Users = () => {
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
label: 'تاریخ عضویت',
|
label: 'تاریخ عضویت',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (value) => new Date(value).toLocaleDateString('fa-IR')
|
render: (value) => formatDate(value)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|
@ -177,19 +176,20 @@ const Users = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-3 space-x-reverse">
|
||||||
<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-4 w-4 ml-2" />
|
<Plus className="h-5 w-5" />
|
||||||
افزودن کاربر
|
</button>
|
||||||
</Button>
|
|
||||||
</PermissionWrapper>
|
</PermissionWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -211,7 +211,9 @@ const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<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">
|
||||||
|
|
@ -233,6 +235,7 @@ const Users = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
title={editingUser ? "ویرایش کاربر" : "افزودن کاربر"}
|
||||||
isOpen={showUserModal}
|
isOpen={showUserModal}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
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 || "خطا در حذف کاربر ادمین");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
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 || "خطا در ورود");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
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 || "خطا در حذف دستهبندی");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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