feat: add remaining UI improvements and new features

- Add cypress test configuration and e2e tests
- Update various components styling and functionality
- Add product detail page and other new features
- General codebase improvements and fixes
This commit is contained in:
hossein taromi 2025-07-28 13:22:44 +03:30
parent c2f938bda8
commit 88958ee63a
48 changed files with 5256 additions and 602 deletions

27
cypress.config.ts Normal file
View File

@ -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",
},
});

106
cypress/e2e/auth.cy.ts Normal file
View File

@ -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");
});
});

View File

@ -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");
});
});
});

View File

@ -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");
});
});

View File

@ -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", "داشبورد");
});
});

View File

@ -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");
});
});

View File

@ -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");
});
});
});

144
cypress/e2e/products.cy.ts Normal file
View File

@ -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");
});
});

View File

@ -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");
});
});
});

View File

@ -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");
});
});
});

49
cypress/e2e/smoke.cy.ts Normal file
View File

@ -0,0 +1,49 @@
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("/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");
});
});

View File

@ -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");
});
});
});

131
cypress/e2e/users.cy.ts Normal file
View File

@ -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");
});
});

View File

@ -0,0 +1,2 @@
# This would be a test image file
# For demo purposes, this represents an image placeholder

View File

@ -0,0 +1 @@
# This is an invalid file format for testing file upload validation

View File

@ -0,0 +1,33 @@
// ***********************************************
// 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");
});

View File

@ -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 />)

31
cypress/support/e2e.ts Normal file
View File

@ -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>;
}
}
}

2251
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,12 @@
"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" "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",
@ -30,6 +35,8 @@
"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": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
@ -37,10 +44,12 @@
"@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",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vite": "^4.4.5" "vite": "^4.4.5"

View File

@ -42,6 +42,7 @@ import CategoryFormPage from './pages/categories/category-form/CategoryFormPage'
// Products Pages // Products Pages
import ProductsListPage from './pages/products/products-list/ProductsListPage'; import ProductsListPage from './pages/products/products-list/ProductsListPage';
import ProductFormPage from './pages/products/product-form/ProductFormPage'; import ProductFormPage from './pages/products/product-form/ProductFormPage';
import ProductDetailPage from './pages/products/product-detail/ProductDetailPage';
const ProtectedRoute = ({ children }: { children: any }) => { const ProtectedRoute = ({ children }: { children: any }) => {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@ -102,6 +103,7 @@ const AppRoutes = () => {
{/* Products Routes */} {/* Products Routes */}
<Route path="products/create" element={<ProductFormPage />} /> <Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="products/:id/edit" element={<ProductFormPage />} /> <Route path="products/:id/edit" element={<ProductFormPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -1,44 +1,52 @@
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';
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}>
<RechartsBarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" /> <CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<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: 10 }}
interval={0}
angle={-45}
textAnchor="end"
height={60}
/> />
<YAxis <YAxis
className="text-gray-600 dark:text-gray-400" className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }} tick={{ fontSize: 10 }}
width={40}
/> />
<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',
}} }}
labelStyle={{ color: 'var(--tooltip-text)' }}
/> />
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} /> <Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} />
</RechartsBarChart> </RechartsBarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
}; };

View File

@ -1,44 +1,59 @@
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';
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}>
<RechartsLineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" /> <CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<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: 10 }}
interval={0}
angle={-45}
textAnchor="end"
height={60}
/> />
<YAxis <YAxis
className="text-gray-600 dark:text-gray-400" className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 12 }} tick={{ fontSize: 10 }}
width={40}
/> />
<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',
}} }}
labelStyle={{ color: 'var(--tooltip-text)' }}
/> />
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} /> <Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
activeDot={{ r: 5 }}
/>
</RechartsLineChart> </RechartsLineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
}; };

View File

@ -1,8 +1,8 @@
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';
interface PieChartProps { interface PieChartProps {
data: ChartData[]; data: any[];
title?: string; title?: string;
colors?: string[]; colors?: string[];
} }
@ -10,22 +10,44 @@ 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 better mobile experience
const CustomLegend = (props: any) => {
const { payload } = props;
return ( return (
<div className="card p-6"> <div className="flex flex-wrap justify-center gap-2 mt-3">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-1 text-xs sm:text-sm">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
{entry.value}: {entry.payload.value}
</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">
<ResponsiveContainer width="100%" height={280} minHeight={220}>
<RechartsPieChart> <RechartsPieChart>
<Pie <Pie
data={data} data={data}
cx="50%" cx="50%"
cy="50%" cy="45%"
labelLine={false} labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} // Remove the overlapping labels
outerRadius={80} label={false}
outerRadius="65%"
fill="#8884d8" fill="#8884d8"
dataKey="value" dataKey="value"
> >
@ -35,15 +57,24 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
</Pie> </Pie>
<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: '14px',
}}
formatter={(value, name) => [`${value}`, name]}
/>
<Legend
content={<CustomLegend />}
wrapperStyle={{
paddingTop: '10px'
}} }}
labelStyle={{ color: 'var(--tooltip-text)' }}
/> />
</RechartsPieChart> </RechartsPieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
}; };

View File

@ -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-3 sm:p-4 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-2 sm:p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
<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>

View File

@ -1,126 +1,76 @@
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 { User, Mail, Phone } from 'lucide-react';
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('شماره تلفن الزامی است'),
});
interface UserFormProps { interface UserFormProps {
initialData?: Partial<UserFormData>;
onSubmit: (data: UserFormData) => void; onSubmit: (data: UserFormData) => void;
onCancel: () => void; defaultValues?: Partial<UserFormData>;
loading?: boolean; isLoading?: boolean;
isEdit?: boolean;
} }
export const UserForm = ({ export const UserForm = ({ onSubmit, defaultValues, 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<UserFormData>({
resolver: yupResolver(userSchema) as any, resolver: yupResolver(userSchema),
mode: 'onChange', defaultValues,
defaultValues: initialData, mode: 'onChange'
}); });
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)} 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>

View File

@ -2,6 +2,7 @@ import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { SectionTitle } from '../ui/Typography';
interface HeaderProps { interface HeaderProps {
onMenuClick: () => void; onMenuClick: () => void;
@ -15,19 +16,17 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
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-sm 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 py-3">
<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"
@ -47,14 +46,14 @@ 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?.first_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?.first_name} {user?.last_name} {user?.first_name} {user?.last_name}
</span> </span>
</button> </button>

View File

@ -7,17 +7,19 @@ 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">
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
</div> </div>

View File

@ -8,13 +8,15 @@ import {
Key, Key,
LogOut, LogOut,
ChevronDown, ChevronDown,
ChevronRight, ChevronLeft,
Package, Package,
FolderOpen, FolderOpen,
Sliders Sliders,
X
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper'; import { PermissionWrapper } from '../common/PermissionWrapper';
import { SectionTitle, SmallText } from '../ui/Typography';
interface MenuItem { interface MenuItem {
title: string; title: string;
@ -77,9 +79,14 @@ const menuItems: MenuItem[] = [
} }
]; ];
export const Sidebar = () => { interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const { user, logout, hasPermission } = useAuth(); const { user, logout, hasPermission } = useAuth();
const [expandedItems, setExpandedItems] = React.useState<string[]>(['مدیریت محصولات', 'مدیریت سیستم']); const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
const toggleExpanded = (title: string) => { const toggleExpanded = (title: string) => {
setExpandedItems(prev => setExpandedItems(prev =>
@ -108,7 +115,7 @@ export const Sidebar = () => {
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
) : ( ) : (
<ChevronRight className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
)} )}
</button> </button>
{isExpanded && item.children && ( {isExpanded && item.children && (
@ -123,10 +130,16 @@ export const Sidebar = () => {
const menuContent = ( const menuContent = (
<NavLink <NavLink
to={item.path!} to={item.path!}
onClick={() => {
// Close mobile menu when clicking a link
if (window.innerWidth < 1024) {
onClose();
}
}}
className={({ isActive }) => className={({ isActive }) =>
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${isActive `w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${isActive
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400' ? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white'
}` }`
} }
style={{ paddingLeft: `${paddingLeft + 16}px` }} style={{ paddingLeft: `${paddingLeft + 16}px` }}
@ -148,16 +161,45 @@ export const Sidebar = () => {
}; };
return ( return (
<div className="flex h-full w-64 flex-col bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700"> <>
{/* Logo */} {/* Mobile overlay */}
<div className="flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700"> {isOpen && (
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100"> <div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={onClose}
/>
)}
{/* Sidebar */}
<div className={`
fixed lg:static inset-y-0 right-0 z-50
w-64 transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:block
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
flex flex-col bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700
`}>
{/* Mobile close button */}
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
<SectionTitle>
پنل مدیریت پنل مدیریت
</h1> </SectionTitle>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
{/* Logo - desktop only */}
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
<SectionTitle>
پنل مدیریت
</SectionTitle>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 space-y-1 px-4 py-6"> <nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
{menuItems.map(item => renderMenuItem(item))} {menuItems.map(item => renderMenuItem(item))}
</nav> </nav>
@ -170,12 +212,12 @@ export const Sidebar = () => {
</span> </span>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> <SmallText>
{user?.first_name} {user?.last_name} {user?.first_name} {user?.last_name}
</p> </SmallText>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate"> <SmallText>
{user?.username} {user?.username}
</p> </SmallText>
</div> </div>
<button <button
onClick={logout} onClick={logout}
@ -186,5 +228,6 @@ export const Sidebar = () => {
</div> </div>
</div> </div>
</div> </div>
</>
); );
}; };

View File

@ -1,54 +1,49 @@
import { forwardRef } from 'react'; import React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { Label } from './Typography';
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?: any;
disabled?: 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, ...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
);
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">
{label}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input <input
ref={ref} ref={ref}
type={type} id={id}
placeholder={placeholder} className={inputClasses}
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} {...props}
/> />
</div> {helperText && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
)}
{error && ( {error && (
<p className="text-sm text-red-600 dark:text-red-400"> <p className="text-xs text-red-600 dark:text-red-400">{error}</p>
{error}
</p>
)} )}
</div> </div>
); );
} }
); );
Input.displayName = 'Input';

View File

@ -1,12 +1,16 @@
import { useEffect } from 'react'; import React, { useEffect } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Button } from './Button';
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 +18,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 +46,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 +58,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-lg shadow-xl
transform transition-all
`}> `}>
{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>

View File

@ -30,11 +30,31 @@
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 {
@ -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;
}
}
} }

View File

@ -1,4 +1,4 @@
import { Users, ShoppingBag, DollarSign, TrendingUp } from 'lucide-react'; import { Users, ShoppingBag, DollarSign, TrendingUp, BarChart3, Plus } from 'lucide-react';
import { StatsCard } from '../components/dashboard/StatsCard'; import { StatsCard } from '../components/dashboard/StatsCard';
import { BarChart } from '../components/charts/BarChart'; import { BarChart } from '../components/charts/BarChart';
import { LineChart } from '../components/charts/LineChart'; import { LineChart } from '../components/charts/LineChart';
@ -6,6 +6,7 @@ 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 { 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 = [ const statsData = [
@ -98,55 +99,69 @@ const userColumns: TableColumn[] = [
export const Dashboard = () => { 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 justify-start gap-3">
<div className="flex space-x-4"> <button
<Button variant="secondary"> className="flex items-center justify-center w-12 h-12 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors duration-200 text-gray-600 dark:text-gray-300"
گزارشگیری title="گزارش‌گیری"
</Button> >
<BarChart3 className="h-5 w-5" />
</button>
<PermissionWrapper permission={25}> <PermissionWrapper permission={25}>
<Button> <button
اضافه کردن 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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> {/* Stats Cards - Mobile responsive grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
{statsData.map((stat, index) => ( {statsData.map((stat, index) => (
<StatsCard key={index} {...stat} /> <StatsCard key={index} {...stat} />
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> {/* Charts - Better mobile layout */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
<div className="min-w-0">
<BarChart <BarChart
data={chartData} data={chartData}
title="فروش ماهانه" title="فروش ماهانه"
color="#3b82f6" color="#3b82f6"
/> />
</div>
<div className="min-w-0">
<LineChart <LineChart
data={chartData} data={chartData}
title="روند رشد" 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> </CardTitle>
<div className="overflow-x-auto">
<Table <Table
columns={userColumns} columns={userColumns}
data={recentUsers} data={recentUsers}
/> />
</div> </div>
</div> </div>
<div> </div>
<div className="min-w-0">
<PieChart <PieChart
data={pieData} data={pieData}
title="دستگاه‌های کاربری" title="دستگاه‌های کاربری"
@ -154,6 +169,6 @@ export const Dashboard = () => {
/> />
</div> </div>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -1,7 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Bell, Check, X, Plus, Search, 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>
); );
}; };

View File

@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Search, Filter, ShoppingCart, TrendingUp } from 'lucide-react'; import { Plus, Search, Filter, Package, ShoppingCart, DollarSign, Clock } 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 { TableColumn } from '../types'; import { TableColumn } from '../types';
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
const allOrders = [ const allOrders = [
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' }, { id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
@ -100,24 +102,11 @@ export const Orders = () => {
}, 0); }, 0);
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">
{filteredOrders.length} سفارش یافت شد {filteredOrders.length} سفارش یافت شد
</p> </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="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="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
@ -125,19 +114,19 @@ export const Orders = () => {
<ShoppingCart className="h-8 w-8 text-blue-600" /> <ShoppingCart 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">{allOrders.length}</p> <StatValue>{allOrders.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">
<TrendingUp 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>
{allOrders.filter(o => o.status === 'تحویل شده').length} {allOrders.filter(o => o.status === 'تحویل شده').length}
</p> </StatValue>
</div> </div>
</div> </div>
</div> </div>
@ -156,7 +145,7 @@ export const Orders = () => {
<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">
<TrendingUp className="h-8 w-8 text-purple-600" /> <DollarSign className="h-8 w-8 text-purple-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-xl font-bold text-gray-900 dark:text-gray-100"> <p className="text-xl font-bold text-gray-900 dark:text-gray-100">
@ -198,6 +187,6 @@ export const Orders = () => {
/> />
</div> </div>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -1,10 +1,12 @@
import { useState } from 'react'; import React, { 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: '۱۴۰۲/۰۸/۱۵' },
@ -103,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">
@ -126,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">
@ -134,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>
@ -144,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>
@ -156,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>
@ -195,7 +191,7 @@ const Products = () => {
/> />
</div> </div>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -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: '۰۹۱۲۳۴۵۶۷۸۹' },
@ -111,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>
@ -181,6 +183,6 @@ export const Users = () => {
isEdit={!!editingUser} isEdit={!!editingUser}
/> />
</Modal> </Modal>
</div> </PageContainer>
); );
}; };

View File

@ -176,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>

View File

@ -12,6 +12,7 @@ import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete"; import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
const adminUserSchema = yup.object({ const adminUserSchema = yup.object({
first_name: yup.string().required('نام الزامی است').min(2, 'نام باید حداقل 2 کاراکتر باشد'), first_name: yup.string().required('نام الزامی است').min(2, 'نام باید حداقل 2 کاراکتر باشد'),
@ -139,10 +140,7 @@ const AdminUserFormPage = () => {
); );
} }
return ( const backButton = (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button <Button
variant="secondary" variant="secondary"
onClick={handleBack} onClick={handleBack}
@ -151,19 +149,19 @@ const AdminUserFormPage = () => {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
بازگشت بازگشت
</Button> </Button>
<div> );
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش کاربر ادمین' : 'ایجاد کاربر ادمین جدید'} return (
</h1> <PageContainer className="max-w-2xl mx-auto">
<p className="text-gray-600 dark:text-gray-400 mt-1"> <FormHeader
{isEdit ? 'ویرایش اطلاعات کاربر ادمین' : 'اطلاعات کاربر ادمین جدید را وارد کنید'} title={isEdit ? 'ویرایش کاربر ادمین' : 'ایجاد کاربر ادمین جدید'}
</p> subtitle={isEdit ? 'ویرایش اطلاعات کاربر ادمین' : 'اطلاعات کاربر ادمین جدید را وارد کنید'}
</div> backButton={backButton}
</div> />
{/* Form */} {/* Form */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6"> <div className="card p-4 sm:p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input <Input
label="نام" label="نام"
@ -259,7 +257,7 @@ const AdminUserFormPage = () => {
</div> </div>
</form> </form>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Trash2, Edit3, Plus, Eye, Users, UserPlus } from "lucide-react"; import { Trash2, Edit3, Plus, Eye, Users, UserPlus } from "lucide-react";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
// Skeleton Loading Component // Skeleton Loading Component
const AdminUserTableSkeleton = () => ( const AdminUserTableSkeleton = () => (
@ -134,25 +135,28 @@ const AdminUsersListPage = () => {
} }
return ( return (
<div className="p-6 space-y-6"> <PageContainer>
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2"> <div className="flex items-center gap-2 mb-2">
<Users className="h-6 w-6" /> <Users className="h-6 w-6" />
مدیریت کاربران ادمین <PageTitle>مدیریت کاربران ادمین</PageTitle>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مدیریت کاربران دسترسی به پنل ادمین
</p>
</div> </div>
<Button onClick={handleCreate} className="flex items-center gap-2"> <p className="text-gray-600 dark:text-gray-400">مدیریت کاربران دسترسی به پنل ادمین</p>
<UserPlus className="h-4 w-4" /> </div>
کاربر ادمین جدید
</Button> <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>
</div> </div>
{/* Filters */} {/* 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="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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
@ -363,7 +367,7 @@ const AdminUsersListPage = () => {
</div> </div>
</div> </div>
</Modal> </Modal>
</div> </PageContainer>
); );
}; };

View File

@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
const permissionSchema = yup.object({ const permissionSchema = yup.object({
title: yup.string().required('عنوان الزامی است').min(3, 'عنوان باید حداقل 3 کاراکتر باشد'), title: yup.string().required('عنوان الزامی است').min(3, 'عنوان باید حداقل 3 کاراکتر باشد'),
@ -87,9 +88,12 @@ const PermissionFormPage = () => {
} }
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <PageContainer>
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <FormHeader
title={isEdit ? 'ویرایش دسترسی' : 'ایجاد دسترسی جدید'}
subtitle={isEdit ? 'ویرایش اطلاعات دسترسی' : 'اطلاعات دسترسی جدید را وارد کنید'}
backButton={
<Button <Button
variant="secondary" variant="secondary"
onClick={handleBack} onClick={handleBack}
@ -98,15 +102,8 @@ const PermissionFormPage = () => {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
بازگشت بازگشت
</Button> </Button>
<div> }
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> />
{isEdit ? 'ویرایش دسترسی' : 'ایجاد دسترسی جدید'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isEdit ? 'ویرایش اطلاعات دسترسی' : 'اطلاعات دسترسی جدید را وارد کنید'}
</p>
</div>
</div>
{/* Form */} {/* Form */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
@ -119,9 +116,9 @@ const PermissionFormPage = () => {
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <Label htmlFor="description">
توضیحات توضیحات
</label> </Label>
<textarea <textarea
{...register('description')} {...register('description')}
rows={4} rows={4}
@ -152,7 +149,7 @@ const PermissionFormPage = () => {
</div> </div>
</form> </form>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { usePermissions } from '../core/_hooks'; import { usePermissions } from '../core/_hooks';
import { Permission } from '../core/_models'; import { Permission } from '../core/_models';
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Shield } from "lucide-react"; import { Shield, Plus } from "lucide-react";
// Skeleton Loading Component // Skeleton Loading Component
const PermissionsTableSkeleton = () => ( const PermissionsTableSkeleton = () => (
@ -102,6 +102,13 @@ const PermissionsListPage = () => {
نمایش دسترسیهای سیستم نمایش دسترسیهای سیستم
</p> </p>
</div> </div>
<button
onClick={() => window.location.href = '/permissions/create'}
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>
</div> </div>
{/* Filters */} {/* Filters */}

View File

@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { ArrowRight, Settings, Plus, Trash2 } from "lucide-react"; import { ArrowRight, Settings, Plus, Trash2 } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
const maintenanceSchema = yup.object({ const maintenanceSchema = yup.object({
title: yup.string().required('عنوان نگهداری الزامی است'), title: yup.string().required('عنوان نگهداری الزامی است'),
@ -115,11 +116,7 @@ const ProductOptionFormPage = () => {
); );
} }
return ( const backButton = (
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => navigate('/product-options')} onClick={() => navigate('/product-options')}
@ -128,13 +125,19 @@ const ProductOptionFormPage = () => {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
برگشت برگشت
</Button> </Button>
<div className="flex items-center gap-2"> );
<Settings className="h-6 w-6 text-primary-600" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> return (
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'} <PageContainer className="max-w-4xl mx-auto">
</h1> <FormHeader
</div> title={isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'}
</div> subtitle="اطلاعات گزینه محصول را وارد کنید"
backButton={backButton}
/>
<div className="card">
<div className="p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
<SectionTitle>اطلاعات اصلی</SectionTitle>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
@ -158,7 +161,7 @@ const ProductOptionFormPage = () => {
</div> </div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">اطلاعات نگهداری</h3> <SectionTitle className="mb-4">اطلاعات نگهداری</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input <Input
label="عنوان نگهداری" label="عنوان نگهداری"
@ -189,7 +192,7 @@ const ProductOptionFormPage = () => {
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">گزینهها</h3> <SectionTitle>گزینهها</SectionTitle>
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
@ -265,7 +268,7 @@ const ProductOptionFormPage = () => {
</div> </div>
</form> </form>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -100,20 +100,23 @@ const ProductOptionsListPage = () => {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Settings className="h-6 w-6" /> <Settings className="h-6 w-6" />
مدیریت گزینههای محصول مدیریت گزینههای محصول
</h1> </h1>
<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>
<Button onClick={handleCreate} className="flex items-center gap-2"> <button
<Plus className="h-4 w-4" /> 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"
</Button> title="گزینه محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
</div> </div>
{/* Filters */} {/* Filters */}

View File

@ -91,6 +91,7 @@ export interface CreateProductRequest {
total_sold: number; total_sold: number;
type: number; type: number;
attributes?: Record<string, any>; attributes?: Record<string, any>;
images?: number[];
variants?: CreateVariantRequest[]; variants?: CreateVariantRequest[];
} }
@ -105,6 +106,7 @@ export interface UpdateProductRequest {
total_sold: number; total_sold: number;
type: number; type: number;
attributes?: Record<string, any>; attributes?: Record<string, any>;
images?: number[];
variants?: UpdateVariantRequest[]; variants?: UpdateVariantRequest[];
} }

View File

@ -0,0 +1,324 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models';
const ProductDetailPage = () => {
const navigate = useNavigate();
const { id = "" } = useParams();
const { data: product, isLoading, error } = useProduct(id);
if (isLoading) return <LoadingSpinner />;
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
if (!product) return <div>محصول یافت نشد</div>;
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
return (
<div className="p-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={() => navigate('/products')}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
جزئیات محصول
</h1>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => navigate(`/products/${id}/edit`)}
className="flex items-center gap-2"
>
<Edit className="h-4 w-4" />
ویرایش
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* اطلاعات اصلی */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6">
اطلاعات محصول
</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام محصول
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{product.name}
</p>
</div>
</div>
{product.description && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
توضیحات
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100">
{product.description}
</p>
</div>
</div>
)}
{product.design_style && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
سبک طراحی
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100">
{product.design_style}
</p>
</div>
</div>
)}
<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>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100">
{PRODUCT_TYPE_LABELS[product.type] || 'نامشخص'}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${product.enabled
? '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'
}`}>
{product.enabled ? 'فعال' : 'غیرفعال'}
</span>
</div>
</div>
</div>
</div>
</div>
{/* تصاویر محصول */}
{product.images && product.images.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تصاویر محصول
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{product.images.map((image, index) => (
<div key={image.id || index} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<Eye className="h-6 w-6 text-white" />
</div>
</div>
))}
</div>
</div>
)}
{/* محصول متغیر */}
{product.variants && product.variants.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
نسخههای محصول
</h3>
<div className="space-y-4">
{product.variants.map((variant, index) => (
<div key={variant.id || index} className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت:</span>
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${variant.enabled
? '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'
}`}>
{variant.enabled ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">موجودی:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.stock_number}
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">وزن:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.weight} گرم
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">درصد سود:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.profit_percentage}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* اطلاعات جانبی */}
<div className="space-y-6">
{/* آمار */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
آمار
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد فروش
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.total_sold || 0}
</span>
</div>
{product.variants && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-blue-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد نسخهها
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.variants.length}
</span>
</div>
)}
{product.images && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Image className="h-4 w-4 text-purple-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد تصاویر
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.images.length}
</span>
</div>
)}
</div>
</div>
{/* دسته‌بندی‌ها */}
{product.categories && product.categories.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
دستهبندیها
</h3>
<div className="space-y-2">
{product.categories.map((category) => (
<div
key={category.id}
className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
>
<Tag className="h-4 w-4 text-blue-500" />
<span className="text-blue-900 dark:text-blue-100 font-medium">
{category.name}
</span>
</div>
))}
</div>
</div>
)}
{/* گزینه محصول */}
{product.product_option && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
گزینه محصول
</h3>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{product.product_option.name}
</p>
{product.product_option.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{product.product_option.description}
</p>
)}
</div>
</div>
)}
{/* اطلاعات زمانی */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
اطلاعات زمانی
</h3>
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تاریخ ایجاد
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(product.created_at).toLocaleDateString('fa-IR')}
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<FileText className="h-4 w-4 text-orange-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
آخرین بهروزرسانی
</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(product.updated_at).toLocaleDateString('fa-IR')}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ProductDetailPage;

View File

@ -15,6 +15,7 @@ import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader"; import { FileUploader } from "@/components/ui/FileUploader";
import { VariantManager } from "@/components/ui/VariantManager"; import { VariantManager } from "@/components/ui/VariantManager";
import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react"; import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
const productSchema = yup.object({ const productSchema = yup.object({
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'), name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
@ -67,7 +68,7 @@ const ProductFormPage = () => {
design_style: '', design_style: '',
enabled: true, enabled: true,
total_sold: 0, total_sold: 0,
type: 0, type: 1, // هارد کد شده به VARIABLE
category_ids: [], category_ids: [],
product_option_id: undefined, product_option_id: undefined,
attributes: {}, attributes: {},
@ -80,18 +81,33 @@ const ProductFormPage = () => {
useEffect(() => { useEffect(() => {
if (isEdit && product) { if (isEdit && product) {
// تبدیل variants از ProductVariant به ProductVariantFormData
const formVariants = product.variants?.map(variant => ({
id: variant.id,
enabled: variant.enabled,
fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage,
stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed,
stock_number: variant.stock_number,
weight: variant.weight,
attributes: variant.attributes || {},
meta: variant.meta || {},
images: variant.images || []
})) || [];
reset({ reset({
name: product.name, name: product.name,
description: product.description || '', description: product.description || '',
design_style: product.design_style || '', design_style: product.design_style || '',
enabled: product.enabled, enabled: product.enabled,
total_sold: product.total_sold || 0, total_sold: product.total_sold || 0,
type: product.type || 0, type: 1, // هارد کد شده به VARIABLE
category_ids: product.category_ids || [], category_ids: product.category_ids || [],
product_option_id: product.product_option_id || undefined, product_option_id: product.product_option_id || undefined,
attributes: product.attributes || {}, attributes: product.attributes || {},
images: product.images || [], images: product.images || [],
variants: [] variants: formVariants
}); });
setUploadedImages(product.images || []); setUploadedImages(product.images || []);
setAttributes(product.attributes || {}); setAttributes(product.attributes || {});
@ -147,27 +163,63 @@ const ProductFormPage = () => {
}; };
const onSubmit = (data: ProductFormData) => { const onSubmit = (data: ProductFormData) => {
const submitData = { const baseSubmitData = {
...data, name: data.name,
description: data.description,
design_style: data.design_style,
enabled: data.enabled,
total_sold: data.total_sold,
type: 1, // هارد کد شده به VARIABLE
attributes, attributes,
category_ids: data.category_ids.length > 0 ? data.category_ids : [], category_ids: data.category_ids.length > 0 ? data.category_ids : [],
product_option_id: data.product_option_id || undefined, product_option_id: data.product_option_id || undefined,
variants: data.variants || [] images: uploadedImages.map(img => parseInt(img.id)) // فقط ID های تصاویر به صورت عدد ارسال می‌شود
}; };
console.log('Submitting product data:', submitData); console.log('Submitting product data:', baseSubmitData);
if (isEdit && id) { if (isEdit && id) {
// برای update، variants باید شامل ID باشه
const updateVariants = data.variants?.map(variant => ({
id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید)
enabled: variant.enabled,
fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage,
stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed,
stock_number: variant.stock_number,
weight: variant.weight,
attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || [];
updateProduct({ updateProduct({
id: parseInt(id), id: parseInt(id),
...submitData ...baseSubmitData,
variants: updateVariants
}, { }, {
onSuccess: () => { onSuccess: () => {
navigate('/products'); navigate('/products');
} }
}); });
} else { } else {
createProduct(submitData, { // برای create، variants نباید ID داشته باشه
const createVariants = data.variants?.map(variant => ({
enabled: variant.enabled,
fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage,
stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed,
stock_number: variant.stock_number,
weight: variant.weight,
attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || [];
createProduct({
...baseSubmitData,
variants: createVariants
}, {
onSuccess: () => { onSuccess: () => {
navigate('/products'); navigate('/products');
} }
@ -199,10 +251,7 @@ const ProductFormPage = () => {
description: `تعداد گزینه‌ها: ${(option.options || []).length}` description: `تعداد گزینه‌ها: ${(option.options || []).length}`
})); }));
return ( const backButton = (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button <Button
variant="secondary" variant="secondary"
onClick={handleBack} onClick={handleBack}
@ -211,16 +260,15 @@ const ProductFormPage = () => {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
بازگشت بازگشت
</Button> </Button>
<div> );
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Package className="h-6 w-6" /> return (
{isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'} <PageContainer className="max-w-6xl mx-auto">
</h1> <FormHeader
<p className="text-gray-600 dark:text-gray-400 mt-1"> title={isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'}
{isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'} subtitle={isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'}
</p> backButton={backButton}
</div> />
</div>
{/* Form */} {/* Form */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
@ -251,24 +299,7 @@ const ProductFormPage = () => {
</label> </label>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع محصول
</label>
<select
{...register('type')}
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"
>
{Object.entries(PRODUCT_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{errors.type && (
<p className="text-red-500 text-sm mt-1">{errors.type.message}</p>
)}
</div>
<Input <Input
label="تعداد فروخته شده" label="تعداد فروخته شده"
@ -554,7 +585,7 @@ const ProductFormPage = () => {
<li> اولین تصویر به عنوان تصویر اصلی محصول استفاده میشود</li> <li> اولین تصویر به عنوان تصویر اصلی محصول استفاده میشود</li>
</ul> </ul>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -143,7 +143,7 @@ const ProductsListPage = () => {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Package className="h-6 w-6" /> <Package className="h-6 w-6" />
@ -153,10 +153,13 @@ const ProductsListPage = () => {
مدیریت محصولات، قیمتها و موجودی مدیریت محصولات، قیمتها و موجودی
</p> </p>
</div> </div>
<Button onClick={handleCreate} className="flex items-center gap-2"> <button
<Plus className="h-4 w-4" /> 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"
</Button> title="محصول جدید"
>
<Plus className="h-5 w-5" />
</button>
</div> </div>
{/* Filters */} {/* Filters */}