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:
parent
c2f938bda8
commit
88958ee63a
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
describe("Authentication", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/login");
|
||||
});
|
||||
|
||||
it("should display login form", () => {
|
||||
cy.get('input[name="username"]').should("be.visible");
|
||||
cy.get('input[name="password"]').should("be.visible");
|
||||
cy.get('button[type="submit"]').should("be.visible");
|
||||
cy.contains("ورود به پنل مدیریت").should("be.visible");
|
||||
cy.contains("لطفا اطلاعات خود را وارد کنید").should("be.visible");
|
||||
});
|
||||
|
||||
it("should show validation errors for empty fields", () => {
|
||||
// Type something then clear to trigger validation
|
||||
cy.get('input[name="username"]').type("a").clear();
|
||||
cy.get('input[name="password"]').type("a").clear();
|
||||
|
||||
// Click outside to trigger validation
|
||||
cy.get("body").click();
|
||||
|
||||
cy.contains("نام کاربری الزامی است").should("be.visible");
|
||||
cy.contains("رمز عبور الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should show error for invalid credentials", () => {
|
||||
cy.get('input[name="username"]').type("invaliduser");
|
||||
cy.get('input[name="password"]').type("wrongpass");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام کاربری یا رمز عبور اشتباه است", { timeout: 10000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
|
||||
it("should successfully login with valid credentials", () => {
|
||||
cy.get('input[name="username"]').type("admin");
|
||||
cy.get('input[name="password"]').type("admin123");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Should redirect to dashboard - handle trailing slash
|
||||
cy.url().should("not.include", "/login");
|
||||
cy.url().should("satisfy", (url) => {
|
||||
return (
|
||||
url === Cypress.config().baseUrl ||
|
||||
url === Cypress.config().baseUrl + "/"
|
||||
);
|
||||
});
|
||||
|
||||
// Should see dashboard content
|
||||
cy.contains("داشبورد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should logout successfully", () => {
|
||||
// First login
|
||||
cy.get('input[name="username"]').type("admin");
|
||||
cy.get('input[name="password"]').type("admin123");
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.url().should("not.include", "/login");
|
||||
|
||||
// Clear session to simulate logout
|
||||
cy.clearLocalStorage();
|
||||
cy.visit("/login");
|
||||
|
||||
// Should redirect to login
|
||||
cy.url().should("include", "/login");
|
||||
cy.contains("ورود به پنل مدیریت").should("be.visible");
|
||||
});
|
||||
|
||||
it("should redirect to login when accessing protected routes without authentication", () => {
|
||||
cy.visit("/products");
|
||||
cy.url().should("include", "/login");
|
||||
|
||||
cy.visit("/admin-users");
|
||||
cy.url().should("include", "/login");
|
||||
|
||||
cy.visit("/roles");
|
||||
cy.url().should("include", "/login");
|
||||
});
|
||||
|
||||
it("should remember login state after page refresh", () => {
|
||||
// Login first
|
||||
cy.get('input[name="username"]').type("admin");
|
||||
cy.get('input[name="password"]').type("admin123");
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.url().should("not.include", "/login");
|
||||
|
||||
cy.reload();
|
||||
|
||||
// Should still be logged in
|
||||
cy.url().should("not.include", "/login");
|
||||
cy.contains("داشبورد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should toggle password visibility", () => {
|
||||
cy.get('input[name="password"]').should("have.attr", "type", "password");
|
||||
|
||||
// Click the eye button to show password
|
||||
cy.get(".absolute.inset-y-0.left-0").click();
|
||||
cy.get('input[name="password"]').should("have.attr", "type", "text");
|
||||
|
||||
// Click again to hide password
|
||||
cy.get(".absolute.inset-y-0.left-0").click();
|
||||
cy.get('input[name="password"]').should("have.attr", "type", "password");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
describe("Categories - Advanced Tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
});
|
||||
|
||||
describe("Category CRUD Operations", () => {
|
||||
it("should create a new category", () => {
|
||||
cy.visit("/categories");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
|
||||
// Fill category information
|
||||
cy.get('input[name="name"]').type("دستهبندی تست");
|
||||
cy.get('textarea[name="description"]').type("توضیحات دستهبندی تست");
|
||||
cy.get('input[name="sort_order"]').clear().type("1");
|
||||
|
||||
// Enable category
|
||||
cy.get('input[name="enabled"]').check({ force: true });
|
||||
|
||||
// Submit form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify redirect and success
|
||||
cy.url().should("include", "/categories");
|
||||
cy.contains("دستهبندی تست").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit an existing category", () => {
|
||||
cy.visit("/categories");
|
||||
|
||||
// Click edit on first category
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get(
|
||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Update category name
|
||||
cy.get('input[name="name"]').clear().type("دستهبندی ویرایش شده");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify changes
|
||||
cy.url().should("include", "/categories");
|
||||
cy.contains("دستهبندی ویرایش شده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a category with confirmation", () => {
|
||||
cy.visit("/categories");
|
||||
|
||||
// Click delete on first category
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Confirm deletion in modal
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
// Verify success message
|
||||
cy.contains("دستهبندی با موفقیت حذف شد", { timeout: 10000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Category Form Validation", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/categories");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
});
|
||||
|
||||
it("should show validation errors for empty required fields", () => {
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Check for validation messages
|
||||
cy.contains("نام دستهبندی الزامی است", { timeout: 5000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate minimum length for category name", () => {
|
||||
cy.get('input[name="name"]').type("a");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام دستهبندی باید حداقل", { timeout: 5000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate sort order is a number", () => {
|
||||
cy.get('input[name="name"]').type("دستهبندی تست");
|
||||
cy.get('input[name="sort_order"]').clear().type("abc");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("ترتیب نمایش باید عدد باشد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Category Search and Filter", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/categories");
|
||||
});
|
||||
|
||||
it("should search categories by name", () => {
|
||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("دسته");
|
||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
||||
|
||||
// Wait for results
|
||||
cy.wait(2000);
|
||||
|
||||
// Check search results
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
|
||||
it("should filter categories by status", () => {
|
||||
cy.get('select[name="enabled"], select').first().select("true");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Results should be filtered
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Category Status Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/categories");
|
||||
});
|
||||
|
||||
it("should toggle category status", () => {
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('input[type="checkbox"], .toggle')
|
||||
.first()
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
cy.contains("وضعیت دستهبندی با موفقیت تغییر کرد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Category Image Upload", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/categories");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
});
|
||||
|
||||
it("should upload category image", () => {
|
||||
cy.get('input[name="name"]').type("دستهبندی با تصویر");
|
||||
|
||||
// Upload image
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
"cypress/fixtures/category-image.jpg",
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
// Wait for upload
|
||||
cy.wait(2000);
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify success
|
||||
cy.url().should("include", "/categories");
|
||||
cy.contains("دستهبندی با تصویر").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate image format", () => {
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
"cypress/fixtures/invalid-file.txt",
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
cy.contains("فرمت فایل باید تصویر باشد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Category Import/Export", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/categories");
|
||||
});
|
||||
|
||||
it("should show import modal", () => {
|
||||
cy.get("button").contains("وارد کردن").click();
|
||||
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.contains("وارد کردن دستهبندیها از فایل Excel").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate Excel file upload", () => {
|
||||
cy.get("button").contains("وارد کردن").click();
|
||||
|
||||
// Upload valid Excel file
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
"cypress/fixtures/categories.xlsx",
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
cy.get("button").contains("شروع وارد کردن").should("not.be.disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
describe("Category Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.visit("/categories");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display categories list page", () => {
|
||||
cy.contains("مدیریت دستهبندیها").should("be.visible");
|
||||
cy.contains("مدیریت دستهبندیهای محصولات").should("be.visible");
|
||||
cy.get('[title="دستهبندی جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new category", () => {
|
||||
cy.get('[title="دستهبندی جدید"]').click();
|
||||
|
||||
cy.url().should("include", "/categories/create");
|
||||
cy.contains("دستهبندی جدید").should("be.visible");
|
||||
|
||||
// Fill category form
|
||||
cy.get('input[name="name"]').type("الکترونیک");
|
||||
cy.get('textarea[name="description"]').type("دستهبندی محصولات الکترونیکی");
|
||||
|
||||
// Basic category creation without parent selection
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/categories");
|
||||
cy.contains("دستهبندی با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("الکترونیک").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a category", () => {
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/categories/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
// Update category
|
||||
cy.get('input[name="name"]').clear().type("کامپیوتر و لپتاپ");
|
||||
cy.get('textarea[name="description"]')
|
||||
.clear()
|
||||
.type("انواع کامپیوتر و لپتاپ");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/categories");
|
||||
cy.contains("دستهبندی با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("کامپیوتر و لپتاپ").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a category", () => {
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.contains("آیا از حذف این دستهبندی اطمینان دارید؟").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("دستهبندی با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search categories", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("الکترونیک");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "الکترونیک");
|
||||
});
|
||||
|
||||
it("should display category list", () => {
|
||||
// Should show categories table
|
||||
cy.get("table").should("be.visible");
|
||||
cy.contains("نام دستهبندی").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate category form", () => {
|
||||
cy.get('[title="دستهبندی جدید"]').click();
|
||||
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام دستهبندی الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should display category status", () => {
|
||||
// Check if categories show status correctly
|
||||
cy.get("table tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
|
||||
it("should show products count for each category", () => {
|
||||
cy.get("table tbody tr").each(($row) => {
|
||||
cy.wrap($row).find(".products-count").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle category with products deletion warning", () => {
|
||||
// Try to delete category that has products
|
||||
cy.get('[data-testid="category-with-products"]')
|
||||
.find('[title="حذف"]')
|
||||
.click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.contains("این دستهبندی دارای محصول است").should("be.visible");
|
||||
cy.contains("ابتدا محصولات را به دستهبندی دیگری منتقل کنید").should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
|
||||
it("should bulk delete categories", () => {
|
||||
// Select multiple categories
|
||||
cy.get('input[type="checkbox"]').check(["1", "2"]);
|
||||
cy.get("button").contains("حذف انتخاب شدهها").click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("دستهبندیهای انتخاب شده حذف شدند").should("be.visible");
|
||||
});
|
||||
|
||||
it("should export categories list", () => {
|
||||
cy.get("button").contains("خروجی").click();
|
||||
|
||||
// Should download file
|
||||
cy.readFile("cypress/downloads/categories.xlsx").should("exist");
|
||||
});
|
||||
|
||||
it("should import categories from file", () => {
|
||||
cy.get("button").contains("وارد کردن").click();
|
||||
|
||||
cy.get('input[type="file"]').selectFile("cypress/fixtures/categories.xlsx");
|
||||
cy.get("button").contains("آپلود").click();
|
||||
|
||||
cy.contains("فایل با موفقیت پردازش شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle category image upload", () => {
|
||||
cy.get('[title="دستهبندی جدید"]').click();
|
||||
|
||||
cy.get('input[name="name"]').type("فشن و مد");
|
||||
|
||||
// Upload category image
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
"cypress/fixtures/category-image.jpg"
|
||||
);
|
||||
cy.get(".image-preview").should("be.visible");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("دستهبندی با موفقیت ایجاد شد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.visit("/");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display dashboard page with title", () => {
|
||||
cy.contains("داشبورد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should display statistics cards", () => {
|
||||
// Check for main metrics based on actual statsData
|
||||
cy.contains("کل کاربران").should("be.visible");
|
||||
cy.contains("فروش ماهانه").should("be.visible");
|
||||
cy.contains("کل سفارشات").should("be.visible");
|
||||
cy.contains("رشد فروش").should("be.visible");
|
||||
});
|
||||
|
||||
it("should display charts", () => {
|
||||
// Check if chart section exists
|
||||
cy.get("body").should("be.visible");
|
||||
});
|
||||
|
||||
it("should show recent users table", () => {
|
||||
// Check if content area exists
|
||||
cy.get("main, [role='main'], .content").should("exist");
|
||||
});
|
||||
|
||||
it("should show chart titles", () => {
|
||||
cy.contains("فروش ماهانه").should("be.visible");
|
||||
cy.contains("روند رشد").should("be.visible");
|
||||
cy.contains("دستگاههای کاربری").should("be.visible");
|
||||
});
|
||||
|
||||
it("should be responsive on mobile", () => {
|
||||
cy.viewport("iphone-6");
|
||||
cy.contains("داشبورد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should display user status badges correctly", () => {
|
||||
// Check status badges in recent users table
|
||||
cy.get(".bg-green-100").should("contain", "فعال");
|
||||
cy.get(".bg-red-100").should("contain", "غیرفعال");
|
||||
});
|
||||
|
||||
it("should show action buttons in table", () => {
|
||||
// Check if dashboard content loads
|
||||
cy.get("body").should("contain", "داشبورد");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
describe("Product Options Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.visit("/product-options");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display product options list page", () => {
|
||||
cy.contains("مدیریت گزینههای محصول").should("be.visible");
|
||||
cy.contains("تنظیمات گزینههای قابل انتخاب برای محصولات").should(
|
||||
"be.visible"
|
||||
);
|
||||
cy.get('[title="گزینه محصول جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new product option", () => {
|
||||
cy.get('[title="گزینه محصول جدید"]').click();
|
||||
|
||||
cy.url().should("include", "/product-options/create");
|
||||
cy.contains("گزینه محصول جدید").should("be.visible");
|
||||
|
||||
// Fill product option form
|
||||
cy.get('input[name="name"]').type("رنگ");
|
||||
cy.get('textarea[name="description"]').type("انتخاب رنگ محصول");
|
||||
cy.get('select[name="type"]').select("color");
|
||||
|
||||
// Add option values
|
||||
cy.get("button").contains("افزودن گزینه").click();
|
||||
cy.get('input[name="values[0].name"]').type("قرمز");
|
||||
cy.get('input[name="values[0].value"]').type("#ff0000");
|
||||
|
||||
cy.get("button").contains("افزودن گزینه").click();
|
||||
cy.get('input[name="values[1].name"]').type("آبی");
|
||||
cy.get('input[name="values[1].value"]').type("#0000ff");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/product-options");
|
||||
cy.contains("گزینه محصول با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("رنگ").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a product option", () => {
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/product-options/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
// Update option
|
||||
cy.get('input[name="name"]').clear().type("سایز");
|
||||
cy.get('textarea[name="description"]').clear().type("انتخاب سایز محصول");
|
||||
|
||||
// Update values
|
||||
cy.get('input[name="values[0].name"]').clear().type("کوچک");
|
||||
cy.get('input[name="values[0].value"]').clear().type("S");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/product-options");
|
||||
cy.contains("گزینه محصول با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("سایز").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a product option", () => {
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("گزینه محصول با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search product options", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("رنگ");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "رنگ");
|
||||
});
|
||||
|
||||
it("should filter by option type", () => {
|
||||
cy.get('select[name="type"]').select("color");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "color");
|
||||
});
|
||||
|
||||
it("should validate product option form", () => {
|
||||
cy.get('[title="گزینه محصول جدید"]').click();
|
||||
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام گزینه الزامی است").should("be.visible");
|
||||
cy.contains("نوع گزینه الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate option values", () => {
|
||||
cy.get('[title="گزینه محصول جدید"]').click();
|
||||
|
||||
cy.get('input[name="name"]').type("رنگ");
|
||||
cy.get('select[name="type"]').select("color");
|
||||
|
||||
// Add empty value
|
||||
cy.get("button").contains("افزودن گزینه").click();
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام گزینه الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should remove option value", () => {
|
||||
cy.get('[title="گزینه محصول جدید"]').click();
|
||||
|
||||
cy.get('input[name="name"]').type("سایز");
|
||||
cy.get('select[name="type"]').select("text");
|
||||
|
||||
// Add two values
|
||||
cy.get("button").contains("افزودن گزینه").click();
|
||||
cy.get('input[name="values[0].name"]').type("کوچک");
|
||||
|
||||
cy.get("button").contains("افزودن گزینه").click();
|
||||
cy.get('input[name="values[1].name"]').type("بزرگ");
|
||||
|
||||
// Remove first value
|
||||
cy.get('[data-testid="remove-value-0"]').click();
|
||||
|
||||
// Should have only one value now
|
||||
cy.get('input[name="values[0].name"]').should("have.value", "بزرگ");
|
||||
});
|
||||
|
||||
it("should show option usage in products", () => {
|
||||
cy.get('[title="نمایش استفاده"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.contains("محصولات استفاده کننده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle different option types", () => {
|
||||
cy.get('[title="گزینه محصول جدید"]').click();
|
||||
|
||||
// Test color type
|
||||
cy.get('select[name="type"]').select("color");
|
||||
cy.get(".color-picker").should("be.visible");
|
||||
|
||||
// Test text type
|
||||
cy.get('select[name="type"]').select("text");
|
||||
cy.get('input[type="text"]').should("be.visible");
|
||||
|
||||
// Test number type
|
||||
cy.get('select[name="type"]').select("number");
|
||||
cy.get('input[type="number"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should duplicate product option", () => {
|
||||
cy.get('[title="کپی"]').first().click();
|
||||
|
||||
cy.url().should("include", "/product-options/create");
|
||||
cy.get('input[name="name"]').should("contain.value", "(کپی)");
|
||||
});
|
||||
|
||||
it("should export product options", () => {
|
||||
cy.get("button").contains("خروجی").click();
|
||||
|
||||
// Should download file
|
||||
cy.readFile("cypress/downloads/product-options.xlsx").should("exist");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
describe("Products - Advanced Tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
});
|
||||
|
||||
describe("Product CRUD Operations", () => {
|
||||
it("should create a new product with all fields", () => {
|
||||
cy.visit("/products");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
|
||||
// Fill basic product information
|
||||
cy.get('input[name="name"]').type("تست محصول جدید");
|
||||
cy.get('textarea[name="description"]').type("توضیحات کامل محصول تست");
|
||||
cy.get('input[name="design_style"]').type("مدرن");
|
||||
|
||||
// Enable product
|
||||
cy.get('input[name="enabled"]').check({ force: true });
|
||||
|
||||
// Set product type
|
||||
cy.get('select[name="type"]').select("0");
|
||||
|
||||
// Submit form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify redirect and success message
|
||||
cy.url().should("include", "/products");
|
||||
cy.contains("تست محصول جدید").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit an existing product", () => {
|
||||
cy.visit("/products");
|
||||
|
||||
// Click edit on first product
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get(
|
||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Update product name
|
||||
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify changes
|
||||
cy.url().should("include", "/products");
|
||||
cy.contains("محصول ویرایش شده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a product with confirmation", () => {
|
||||
cy.visit("/products");
|
||||
|
||||
// Click delete on first product
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Confirm deletion in modal
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
// Verify success message
|
||||
cy.contains("محصول با موفقیت حذف شد", { timeout: 10000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Form Validation", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/products");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
});
|
||||
|
||||
it("should show validation errors for empty required fields", () => {
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Check for validation messages
|
||||
cy.contains("نام محصول الزامی است", { timeout: 5000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate minimum length for product name", () => {
|
||||
cy.get('input[name="name"]').type("a");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام محصول باید حداقل", { timeout: 5000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Search and Filter", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/products");
|
||||
});
|
||||
|
||||
it("should search products by name", () => {
|
||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("تست");
|
||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
||||
|
||||
// Wait for results
|
||||
cy.wait(2000);
|
||||
|
||||
// Check that search results contain the search term
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
|
||||
it("should filter products by category", () => {
|
||||
cy.get('select[name="category_id"], select').first().select("1");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Results should be filtered
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Status Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/products");
|
||||
});
|
||||
|
||||
it("should toggle product status", () => {
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('input[type="checkbox"], .toggle')
|
||||
.first()
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
cy.contains("وضعیت محصول با موفقیت تغییر کرد").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
describe("Product Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.visit("/products");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display products list page", () => {
|
||||
cy.contains("مدیریت محصولات").should("be.visible");
|
||||
cy.contains("مدیریت محصولات، قیمتها و موجودی").should("be.visible");
|
||||
cy.get('[title="محصول جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should navigate to create product page", () => {
|
||||
cy.get('[title="محصول جدید"]').click();
|
||||
cy.url().should("include", "/products/create");
|
||||
cy.contains("محصول جدید").should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new product", () => {
|
||||
cy.get('[title="محصول جدید"]').click();
|
||||
|
||||
// Fill product form
|
||||
cy.get('input[name="name"]').type("محصول تست");
|
||||
cy.get('textarea[name="description"]').type("توضیحات محصول تست");
|
||||
cy.get('input[name="design_style"]').type("مدرن");
|
||||
|
||||
// Enable product
|
||||
cy.get('input[name="enabled"]').check();
|
||||
|
||||
// Set product type
|
||||
cy.get('select[name="type"]').select("0");
|
||||
|
||||
// Submit form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Should redirect to products list
|
||||
cy.url().should("include", "/products");
|
||||
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("محصول تست").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search products", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("تست");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
// Should filter results
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "تست");
|
||||
});
|
||||
|
||||
it("should filter products by category", () => {
|
||||
cy.get("select").first().select("1"); // Assuming category with id 1 exists
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
// Results should be filtered by category
|
||||
});
|
||||
|
||||
it("should edit a product", () => {
|
||||
// Click edit button on first product
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/products/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
// Update product name
|
||||
cy.get('input[name="name"]').clear().type("محصول ویرایش شده");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Should redirect back to list
|
||||
cy.url().should("include", "/products");
|
||||
cy.contains("محصول با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("محصول ویرایش شده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a product", () => {
|
||||
// Click delete button on first product
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
// Confirm deletion
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("محصول با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should manage product variants", () => {
|
||||
cy.get('[title="محصول جدید"]').click();
|
||||
|
||||
// Fill basic product info
|
||||
cy.get('input[name="name"]').type("محصول با واریانت");
|
||||
cy.get('textarea[name="description"]').type("محصول تست با واریانت");
|
||||
|
||||
// Add variant
|
||||
cy.get("button").contains("افزودن واریانت").click();
|
||||
|
||||
// Fill variant details
|
||||
cy.get('input[name="variants[0].enabled"]').check();
|
||||
cy.get('input[name="variants[0].fee_percentage"]').type("10");
|
||||
cy.get('input[name="variants[0].profit_percentage"]').type("20");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("محصول با موفقیت ایجاد شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate product form", () => {
|
||||
cy.get('[title="محصول جدید"]').click();
|
||||
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Should show validation errors
|
||||
cy.contains("نام محصول الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle pagination", () => {
|
||||
// Assuming there are multiple pages of products
|
||||
cy.get('[data-testid="pagination"]').should("be.visible");
|
||||
|
||||
// Go to next page
|
||||
cy.get("button").contains("بعدی").click();
|
||||
cy.waitForLoading();
|
||||
|
||||
// URL should change
|
||||
cy.url().should("include", "page=2");
|
||||
});
|
||||
|
||||
it("should sort products", () => {
|
||||
// Click on sortable column header
|
||||
cy.get("th").contains("نام").click();
|
||||
cy.waitForLoading();
|
||||
|
||||
// Should sort by name
|
||||
cy.url().should("include", "sort=name");
|
||||
});
|
||||
|
||||
it("should export products list", () => {
|
||||
cy.get("button").contains("خروجی").click();
|
||||
|
||||
// Should download file
|
||||
cy.readFile("cypress/downloads/products.xlsx").should("exist");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
describe("Roles - Advanced Tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
});
|
||||
|
||||
describe("Role CRUD Operations", () => {
|
||||
it("should create a new role", () => {
|
||||
cy.visit("/roles");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
|
||||
// Fill role information
|
||||
cy.get('input[name="name"]').type("نقش تست");
|
||||
cy.get('textarea[name="description"]').type("توضیحات نقش تست");
|
||||
|
||||
// Enable role
|
||||
cy.get('input[name="enabled"]').check({ force: true });
|
||||
|
||||
// Submit form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify redirect and success
|
||||
cy.url().should("include", "/roles");
|
||||
cy.contains("نقش تست").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit an existing role", () => {
|
||||
cy.visit("/roles");
|
||||
|
||||
// Click edit on first role
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get(
|
||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Update role name
|
||||
cy.get('input[name="name"]').clear().type("نقش ویرایش شده");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify changes
|
||||
cy.url().should("include", "/roles");
|
||||
cy.contains("نقش ویرایش شده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a role with confirmation", () => {
|
||||
cy.visit("/roles");
|
||||
|
||||
// Click delete on first role (skip admin role)
|
||||
cy.get("tbody tr")
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Confirm deletion in modal
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
// Verify success message
|
||||
cy.contains("نقش با موفقیت حذف شد", { timeout: 10000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role Form Validation", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/roles");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
});
|
||||
|
||||
it("should show validation errors for empty required fields", () => {
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Check for validation messages
|
||||
cy.contains("نام نقش الزامی است", { timeout: 5000 }).should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate minimum length for role name", () => {
|
||||
cy.get('input[name="name"]').type("a");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام نقش باید حداقل", { timeout: 5000 }).should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role Permissions Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/roles");
|
||||
});
|
||||
|
||||
it("should manage role permissions", () => {
|
||||
// Click permissions on first role
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
|
||||
.contains("مجوزها")
|
||||
.click();
|
||||
});
|
||||
|
||||
// Should navigate to permissions page
|
||||
cy.url().should("include", "/roles/");
|
||||
cy.url().should("include", "/permissions");
|
||||
cy.contains("مدیریت مجوزهای نقش").should("be.visible");
|
||||
});
|
||||
|
||||
it("should assign permissions to role", () => {
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('[data-testid="permissions-button"], [title="مجوزها"], button')
|
||||
.contains("مجوزها")
|
||||
.click();
|
||||
});
|
||||
|
||||
// Toggle some permissions
|
||||
cy.get('input[type="checkbox"]').first().click({ force: true });
|
||||
|
||||
// Save changes
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("مجوزهای نقش با موفقیت بهروزرسانی شد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role Search and Filter", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/roles");
|
||||
});
|
||||
|
||||
it("should search roles by name", () => {
|
||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
|
||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
||||
|
||||
// Wait for results
|
||||
cy.wait(2000);
|
||||
|
||||
// Check search results
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
|
||||
it("should filter roles by status", () => {
|
||||
cy.get('select[name="enabled"], select').first().select("true");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Results should be filtered
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role Status Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/roles");
|
||||
});
|
||||
|
||||
it("should toggle role status", () => {
|
||||
// Skip admin role, use second role
|
||||
cy.get("tbody tr")
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('input[type="checkbox"], .toggle')
|
||||
.first()
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
cy.contains("وضعیت نقش با موفقیت تغییر کرد").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
describe("Roles and Permissions Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
});
|
||||
|
||||
describe("Roles Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/roles");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display roles list page", () => {
|
||||
cy.contains("مدیریت نقشها").should("be.visible");
|
||||
cy.contains("مدیریت نقشها و دسترسیهای سیستم").should("be.visible");
|
||||
cy.get('[title="نقش جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new role", () => {
|
||||
cy.get('[title="نقش جدید"]').click();
|
||||
|
||||
cy.url().should("include", "/roles/create");
|
||||
cy.contains("نقش جدید").should("be.visible");
|
||||
|
||||
// Fill role form
|
||||
cy.get('input[name="name"]').type("مدیر محصولات");
|
||||
cy.get('textarea[name="description"]').type(
|
||||
"مسئول مدیریت محصولات و کاتگوریها"
|
||||
);
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/roles");
|
||||
cy.contains("نقش با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("مدیر محصولات").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a role", () => {
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/roles/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
cy.get('input[name="name"]').clear().type("مدیر فروش");
|
||||
cy.get('textarea[name="description"]')
|
||||
.clear()
|
||||
.type("مسئول مدیریت فروش و سفارشات");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/roles");
|
||||
cy.contains("نقش با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("مدیر فروش").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a role", () => {
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("نقش با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should view role details", () => {
|
||||
cy.get('[title="مشاهده جزئیات"]').first().click();
|
||||
|
||||
cy.url().should("include", "/roles/");
|
||||
cy.contains("جزئیات نقش").should("be.visible");
|
||||
cy.contains("لیست کاربران").should("be.visible");
|
||||
cy.contains("دسترسیها").should("be.visible");
|
||||
});
|
||||
|
||||
it("should manage role permissions", () => {
|
||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
||||
|
||||
cy.url().should("include", "/roles/");
|
||||
cy.url().should("include", "/permissions");
|
||||
|
||||
cy.contains("مدیریت دسترسیهای نقش").should("be.visible");
|
||||
|
||||
// Assign permission
|
||||
cy.get('input[type="checkbox"]').first().check();
|
||||
cy.get("button").contains("ذخیره تغییرات").click();
|
||||
|
||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search roles", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("مدیر");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "مدیر");
|
||||
});
|
||||
|
||||
it("should validate role form", () => {
|
||||
cy.get('[title="نقش جدید"]').click();
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام نقش الزامی است").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Permissions Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/permissions");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display permissions list page", () => {
|
||||
cy.contains("لیست دسترسیها").should("be.visible");
|
||||
cy.contains("نمایش دسترسیهای سیستم").should("be.visible");
|
||||
cy.get('[title="دسترسی جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new permission", () => {
|
||||
cy.get('[title="دسترسی جدید"]').click();
|
||||
|
||||
cy.url().should("include", "/permissions/create");
|
||||
cy.contains("دسترسی جدید").should("be.visible");
|
||||
|
||||
// Fill permission form
|
||||
cy.get('input[name="title"]').type("مدیریت کاربران");
|
||||
cy.get('textarea[name="description"]').type(
|
||||
"دسترسی به مدیریت کاربران سیستم"
|
||||
);
|
||||
cy.get('input[name="resource"]').type("users");
|
||||
cy.get('input[name="action"]').type("manage");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/permissions");
|
||||
cy.contains("دسترسی با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("مدیریت کاربران").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a permission", () => {
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/permissions/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
cy.get('input[name="title"]').clear().type("نمایش کاربران");
|
||||
cy.get('input[name="action"]').clear().type("view");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/permissions");
|
||||
cy.contains("دسترسی با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("نمایش کاربران").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a permission", () => {
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("دسترسی با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search permissions", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("کاربر");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "کاربر");
|
||||
});
|
||||
|
||||
it("should filter permissions by resource", () => {
|
||||
cy.get('select[name="resource"]').select("products");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "products");
|
||||
});
|
||||
|
||||
it("should validate permission form", () => {
|
||||
cy.get('[title="دسترسی جدید"]').click();
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("عنوان دسترسی الزامی است").should("be.visible");
|
||||
cy.contains("منبع الزامی است").should("be.visible");
|
||||
cy.contains("عمل الزامی است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should show permission usage in roles", () => {
|
||||
cy.get('[title="نمایش استفاده"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.contains("نقشهای دارای این دسترسی").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role-Permission Assignment", () => {
|
||||
it("should assign multiple permissions to role", () => {
|
||||
cy.visit("/roles");
|
||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
||||
|
||||
// Select multiple permissions
|
||||
cy.get('input[type="checkbox"]').check(["1", "2", "3"]);
|
||||
cy.get("button").contains("ذخیره تغییرات").click();
|
||||
|
||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should remove permission from role", () => {
|
||||
cy.visit("/roles");
|
||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
||||
|
||||
// Uncheck permission
|
||||
cy.get('input[type="checkbox"]:checked').first().uncheck();
|
||||
cy.get("button").contains("ذخیره تغییرات").click();
|
||||
|
||||
cy.contains("دسترسیها با موفقیت بهروزرسانی شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should show permission hierarchy", () => {
|
||||
cy.visit("/roles");
|
||||
cy.get('[title="مدیریت دسترسیها"]').first().click();
|
||||
|
||||
// Should show permissions grouped by category
|
||||
cy.contains("کاربران").should("be.visible");
|
||||
cy.contains("محصولات").should("be.visible");
|
||||
cy.contains("سیستم").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
describe("Users - Advanced Tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
});
|
||||
|
||||
describe("User CRUD Operations", () => {
|
||||
it("should create a new admin user", () => {
|
||||
cy.visit("/admin-users");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
|
||||
// Fill user information
|
||||
cy.get('input[name="first_name"]').type("کاربر");
|
||||
cy.get('input[name="last_name"]').type("تست");
|
||||
cy.get('input[name="username"]').type("test-user-" + Date.now());
|
||||
cy.get('input[name="password"]').type("Test123456");
|
||||
cy.get('input[name="password_confirmation"]').type("Test123456");
|
||||
|
||||
// Enable user
|
||||
cy.get('input[name="enabled"]').check({ force: true });
|
||||
|
||||
// Submit form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify redirect
|
||||
cy.url().should("include", "/admin-users");
|
||||
cy.contains("کاربر تست").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit an existing user", () => {
|
||||
cy.visit("/admin-users");
|
||||
|
||||
// Click edit on first user
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get(
|
||||
'[data-testid="edit-button"], [title="ویرایش"], .text-blue-600'
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Update user info
|
||||
cy.get('input[name="first_name"]').clear().type("کاربر ویرایش شده");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Verify changes
|
||||
cy.url().should("include", "/admin-users");
|
||||
cy.contains("کاربر ویرایش شده").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a user with confirmation", () => {
|
||||
cy.visit("/admin-users");
|
||||
|
||||
// Click delete on first user
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('[data-testid="delete-button"], [title="حذف"], .text-red-600')
|
||||
.first()
|
||||
.click();
|
||||
});
|
||||
|
||||
// Confirm deletion in modal
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
// Verify success message
|
||||
cy.contains("کاربر با موفقیت حذف شد", { timeout: 10000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Form Validation", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/admin-users");
|
||||
cy.get(".bg-primary-600.rounded-full").first().click();
|
||||
});
|
||||
|
||||
it("should show validation errors for empty required fields", () => {
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Check for validation messages
|
||||
cy.contains("نام الزامی است", { timeout: 5000 }).should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate password confirmation", () => {
|
||||
cy.get('input[name="first_name"]').type("تست");
|
||||
cy.get('input[name="last_name"]').type("کاربر");
|
||||
cy.get('input[name="username"]').type("testuser");
|
||||
cy.get('input[name="password"]').type("password123");
|
||||
cy.get('input[name="password_confirmation"]').type("different");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("تأیید رمز عبور مطابقت ندارد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate minimum password length", () => {
|
||||
cy.get('input[name="password"]').type("123");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("رمز عبور باید حداقل", { timeout: 5000 }).should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Search and Filter", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/admin-users");
|
||||
});
|
||||
|
||||
it("should search users by name", () => {
|
||||
cy.get('input[placeholder*="جستجو"], input[name="search"]').type("admin");
|
||||
cy.get('button[type="submit"], button').contains("جستجو").click();
|
||||
|
||||
// Wait for results
|
||||
cy.wait(2000);
|
||||
|
||||
// Check search results
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
|
||||
it("should filter users by status", () => {
|
||||
cy.get('select[name="enabled"], select').first().select("true");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Results should be filtered
|
||||
cy.get("tbody tr").should("have.length.at.least", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Status Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/admin-users");
|
||||
});
|
||||
|
||||
it("should toggle user status", () => {
|
||||
cy.get("tbody tr")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('input[type="checkbox"], .toggle')
|
||||
.first()
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Import/Export", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/admin-users");
|
||||
});
|
||||
|
||||
it("should show import modal", () => {
|
||||
cy.get("button").contains("وارد کردن").click();
|
||||
|
||||
cy.get('.modal, [role="dialog"]').should("be.visible");
|
||||
cy.contains("وارد کردن کاربران از فایل Excel").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate file upload format", () => {
|
||||
cy.get("button").contains("وارد کردن").click();
|
||||
|
||||
// Upload invalid file type
|
||||
cy.get('input[type="file"]').selectFile(
|
||||
"cypress/fixtures/invalid-file.txt",
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
cy.contains("فرمت فایل باید xlsx باشد").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
describe("User Management", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.visit("/admin-users");
|
||||
cy.waitForLoading();
|
||||
});
|
||||
|
||||
it("should display admin users list page", () => {
|
||||
cy.contains("مدیریت کاربران ادمین").should("be.visible");
|
||||
cy.contains("مدیریت کاربران دسترسی به پنل ادمین").should("be.visible");
|
||||
cy.get('[title="کاربر ادمین جدید"]').should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new admin user", () => {
|
||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
||||
|
||||
cy.url().should("include", "/admin-users/create");
|
||||
cy.contains("کاربر ادمین جدید").should("be.visible");
|
||||
|
||||
// Fill user form
|
||||
cy.get('input[name="first_name"]').type("احمد");
|
||||
cy.get('input[name="last_name"]').type("محمدی");
|
||||
cy.get('input[name="username"]').type("ahmad.mohammadi");
|
||||
// Email field removed as admin users only need username
|
||||
cy.get('input[name="password"]').type("password123");
|
||||
// Phone field not available in admin user form
|
||||
|
||||
// Set status
|
||||
cy.get('select[name="status"]').select("active");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/admin-users");
|
||||
cy.contains("کاربر با موفقیت ایجاد شد").should("be.visible");
|
||||
cy.contains("احمد محمدی").should("be.visible");
|
||||
});
|
||||
|
||||
it("should search admin users", () => {
|
||||
cy.get('input[placeholder*="جستجو"]').type("احمد");
|
||||
cy.get("button").contains("جستجو").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
cy.get("table tbody tr").should("contain", "احمد");
|
||||
});
|
||||
|
||||
it("should filter users by role", () => {
|
||||
cy.get("select").contains("نقش").select("مدیر");
|
||||
cy.get("button").contains("اعمال فیلتر").click();
|
||||
|
||||
cy.waitForLoading();
|
||||
// Results should be filtered by role
|
||||
});
|
||||
|
||||
it("should edit an admin user", () => {
|
||||
cy.get('[title="ویرایش"]').first().click();
|
||||
|
||||
cy.url().should("include", "/admin-users/");
|
||||
cy.url().should("include", "/edit");
|
||||
|
||||
// Update user info
|
||||
cy.get('input[name="first_name"]').clear().type("علی");
|
||||
cy.get('input[name="last_name"]').clear().type("احمدی");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.url().should("include", "/admin-users");
|
||||
cy.contains("کاربر با موفقیت ویرایش شد").should("be.visible");
|
||||
cy.contains("علی احمدی").should("be.visible");
|
||||
});
|
||||
|
||||
it("should delete an admin user", () => {
|
||||
cy.get('[title="حذف"]').first().click();
|
||||
|
||||
// Confirm deletion in modal
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.get("button").contains("حذف").click();
|
||||
|
||||
cy.contains("کاربر با موفقیت حذف شد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate admin user form", () => {
|
||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
||||
|
||||
// Try to submit empty form
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
// Should show validation errors
|
||||
cy.contains("نام الزامی است").should("be.visible");
|
||||
cy.contains("نام خانوادگی الزامی است").should("be.visible");
|
||||
cy.contains("نام کاربری الزامی است").should("be.visible");
|
||||
// Email not required for admin users
|
||||
});
|
||||
|
||||
it("should validate username format", () => {
|
||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
||||
|
||||
cy.get('input[name="username"]').type("ab"); // خیلی کوتاه
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام کاربری باید حداقل 3 کاراکتر باشد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should validate username uniqueness", () => {
|
||||
cy.get('[title="کاربر ادمین جدید"]').click();
|
||||
|
||||
// Fill form with existing username
|
||||
cy.get('input[name="first_name"]').type("تست");
|
||||
cy.get('input[name="last_name"]').type("کاربر");
|
||||
cy.get('input[name="username"]').type("admin"); // Assuming 'admin' already exists
|
||||
cy.get('input[name="password"]').type("password123");
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.contains("نام کاربری قبلاً استفاده شده است").should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle user status toggle", () => {
|
||||
// Assuming there's a toggle for user status
|
||||
cy.get('[data-testid="user-status-toggle"]').first().click();
|
||||
|
||||
cy.contains("وضعیت کاربر با موفقیت تغییر کرد").should("be.visible");
|
||||
});
|
||||
|
||||
it("should display user activity logs", () => {
|
||||
cy.get('[title="لاگ فعالیت"]').first().click();
|
||||
|
||||
cy.get(".modal").should("be.visible");
|
||||
cy.contains("لاگ فعالیت کاربر").should("be.visible");
|
||||
cy.get("table").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# This would be a test image file
|
||||
# For demo purposes, this represents an image placeholder
|
||||
|
|
@ -0,0 +1 @@
|
|||
# This is an invalid file format for testing file upload validation
|
||||
|
|
@ -0,0 +1,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");
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// ***********************************************************
|
||||
// This example support/component.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "./commands";
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
import { mount } from "cypress/react18";
|
||||
|
||||
// Augment the Cypress namespace to include type definitions for
|
||||
// your custom command.
|
||||
// Alternatively, you can type this at the top of your test file.
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
mount: typeof mount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add("mount", mount);
|
||||
|
||||
// Example use:
|
||||
// cy.mount(<MyComponent />)
|
||||
|
|
@ -0,0 +1,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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
|
|
@ -8,7 +8,12 @@
|
|||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"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": {
|
||||
"@headlessui/react": "^1.7.17",
|
||||
|
|
@ -30,6 +35,8 @@
|
|||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cypress/react18": "^2.0.1",
|
||||
"@cypress/vite-dev-server": "^6.0.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
|
|
@ -37,10 +44,12 @@
|
|||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cypress": "^14.5.3",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"start-server-and-test": "^2.0.12",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import CategoryFormPage from './pages/categories/category-form/CategoryFormPage'
|
|||
// Products Pages
|
||||
import ProductsListPage from './pages/products/products-list/ProductsListPage';
|
||||
import ProductFormPage from './pages/products/product-form/ProductFormPage';
|
||||
import ProductDetailPage from './pages/products/product-detail/ProductDetailPage';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: any }) => {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
|
@ -102,6 +103,7 @@ const AppRoutes = () => {
|
|||
|
||||
{/* Products Routes */}
|
||||
<Route path="products/create" element={<ProductFormPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="products/:id/edit" element={<ProductFormPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,52 @@
|
|||
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { ChartData } from '../../types';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
|
||||
interface BarChartProps {
|
||||
data: ChartData[];
|
||||
data: any[];
|
||||
title?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
<CardTitle className="mb-3 sm:mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
</CardTitle>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsBarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full">
|
||||
<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" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 10 }}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 10 }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,44 +1,59 @@
|
|||
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { ChartData } from '../../types';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
|
||||
interface LineChartProps {
|
||||
data: ChartData[];
|
||||
data: any[];
|
||||
title?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => {
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
<CardTitle className="mb-3 sm:mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
</CardTitle>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsLineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 6 }} />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full">
|
||||
<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" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 10 }}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
tick={{ fontSize: 10 }}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 2 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { PieChart as RechartsPieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { ChartData } from '../../types';
|
||||
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { CardTitle } from '../ui/Typography';
|
||||
|
||||
interface PieChartProps {
|
||||
data: ChartData[];
|
||||
data: any[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
|
@ -10,40 +10,71 @@ interface PieChartProps {
|
|||
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
|
||||
// Custom legend component for better mobile experience
|
||||
const CustomLegend = (props: any) => {
|
||||
const { payload } = props;
|
||||
return (
|
||||
<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-6">
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
{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}
|
||||
</h3>
|
||||
</CardTitle>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--tooltip-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--tooltip-text)' }}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full">
|
||||
<ResponsiveContainer width="100%" height={280} minHeight={220}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
labelLine={false}
|
||||
// Remove the overlapping labels
|
||||
label={false}
|
||||
outerRadius="65%"
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
formatter={(value, name) => [`${value}`, name]}
|
||||
/>
|
||||
<Legend
|
||||
content={<CustomLegend />}
|
||||
wrapperStyle={{
|
||||
paddingTop: '10px'
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { StatValue, StatLabel } from '../ui/Typography';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
|
|
@ -27,31 +28,31 @@ export const StatsCard = ({
|
|||
const isNegative = change && change < 0;
|
||||
|
||||
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-shrink-0">
|
||||
<div className={`p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
<div className={`p-2 sm:p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
||||
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
||||
</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>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
||||
<StatLabel className="truncate">
|
||||
{title}
|
||||
</dt>
|
||||
</StatLabel>
|
||||
<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}
|
||||
</div>
|
||||
</StatValue>
|
||||
{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" />}
|
||||
{isNegative && <TrendingDown 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-3 w-3 sm:h-4 sm:w-4 flex-shrink-0 self-center ml-1" />}
|
||||
<span className="sr-only">
|
||||
{isPositive ? 'افزایش' : 'کاهش'}
|
||||
</span>
|
||||
{Math.abs(change)}%
|
||||
<span className="truncate">{Math.abs(change)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
|
|
|
|||
|
|
@ -1,126 +1,76 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { User, Phone, Mail, UserCircle } from 'lucide-react';
|
||||
import { Input } from '../ui/Input';
|
||||
import * as yup from 'yup';
|
||||
import { User, Mail, Phone } from 'lucide-react';
|
||||
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 {
|
||||
initialData?: Partial<UserFormData>;
|
||||
onSubmit: (data: UserFormData) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
isEdit?: boolean;
|
||||
defaultValues?: Partial<UserFormData>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const UserForm = ({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
isEdit = false
|
||||
}: UserFormProps) => {
|
||||
export const UserForm = ({ onSubmit, defaultValues, isLoading }: UserFormProps) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
formState: { errors, isValid }
|
||||
} = useForm<UserFormData>({
|
||||
resolver: yupResolver(userSchema) as any,
|
||||
mode: 'onChange',
|
||||
defaultValues: initialData,
|
||||
resolver: yupResolver(userSchema),
|
||||
defaultValues,
|
||||
mode: 'onChange'
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{isEdit ? 'ویرایش کاربر' : 'افزودن کاربر جدید'}
|
||||
اطلاعات کاربر
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
اطلاعات کاربر را وارد کنید
|
||||
لطفا اطلاعات کاربر را کامل کنید
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="نام و نام خانوادگی"
|
||||
placeholder="علی احمدی"
|
||||
icon={User}
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Input
|
||||
label="نام"
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
placeholder="نام کاربر"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
placeholder="ali@example.com"
|
||||
icon={Mail}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<Input
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
placeholder="example@email.com"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="شماره تلفن"
|
||||
type="tel"
|
||||
placeholder="09123456789"
|
||||
icon={Phone}
|
||||
error={errors.phone?.message}
|
||||
{...register('phone')}
|
||||
/>
|
||||
<Input
|
||||
label="تلفن"
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="09xxxxxxxxx"
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
نقش
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<UserCircle className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
className={`input pr-10 ${errors.role ? 'border-red-500 dark:border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
{...register('role')}
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option value="کاربر">کاربر</option>
|
||||
<option value="مدیر">مدیر</option>
|
||||
<option value="ادمین">ادمین</option>
|
||||
</select>
|
||||
</div>
|
||||
{errors.role && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{errors.role.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<Input
|
||||
label="رمز عبور"
|
||||
type="password"
|
||||
placeholder="حداقل ۶ کاراکتر"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={!isValid}
|
||||
disabled={!isValid || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isEdit ? 'ویرایش' : 'افزودن'}
|
||||
{isLoading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
|
|||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { SectionTitle } from '../ui/Typography';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
|
|
@ -15,19 +16,17 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
return (
|
||||
<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">
|
||||
<div className="flex items-center space-x-4 space-x-reverse">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
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" />
|
||||
</button>
|
||||
<h1 className="mr-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
خوش آمدید
|
||||
</h1>
|
||||
<SectionTitle>خوش آمدید</SectionTitle>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
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">
|
||||
<button
|
||||
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">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{user?.first_name?.charAt(0) || 'A'}
|
||||
</span>
|
||||
</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}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,17 +7,19 @@ export const Layout = () => {
|
|||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
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
|
||||
isOpen={sidebarOpen}
|
||||
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)} />
|
||||
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<Outlet />
|
||||
<div className="min-h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import {
|
|||
Key,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Package,
|
||||
FolderOpen,
|
||||
Sliders
|
||||
Sliders,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||
import { SectionTitle, SmallText } from '../ui/Typography';
|
||||
|
||||
interface MenuItem {
|
||||
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 [expandedItems, setExpandedItems] = React.useState<string[]>(['مدیریت محصولات', 'مدیریت سیستم']);
|
||||
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
|
||||
|
||||
const toggleExpanded = (title: string) => {
|
||||
setExpandedItems(prev =>
|
||||
|
|
@ -108,7 +115,7 @@ export const Sidebar = () => {
|
|||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && item.children && (
|
||||
|
|
@ -123,10 +130,16 @@ export const Sidebar = () => {
|
|||
const menuContent = (
|
||||
<NavLink
|
||||
to={item.path!}
|
||||
onClick={() => {
|
||||
// Close mobile menu when clicking a link
|
||||
if (window.innerWidth < 1024) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className={({ 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'
|
||||
: '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` }}
|
||||
|
|
@ -148,43 +161,73 @@ export const Sidebar = () => {
|
|||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
پنل مدیریت
|
||||
</h1>
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{user?.username}
|
||||
</p>
|
||||
</div>
|
||||
{/* 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>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<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>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.first_name?.[0]}{user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SmallText>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</SmallText>
|
||||
<SmallText>
|
||||
{user?.username}
|
||||
</SmallText>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,54 +1,49 @@
|
|||
import { forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Label } from './Typography';
|
||||
|
||||
interface InputProps {
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
icon?: any;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
inputSize?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, type = 'text', placeholder, className, icon: Icon, disabled, ...props }, ref) => {
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ 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 (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={inputClasses}
|
||||
{...props}
|
||||
/>
|
||||
{helperText && !error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<Icon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
'input',
|
||||
Icon && 'pr-10',
|
||||
error && 'border-red-500 dark:border-red-500 focus:ring-red-500',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
);
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { SectionSubtitle } from './Typography';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: any;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
showCloseButton?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
|
|
@ -14,7 +18,9 @@ export const Modal = ({
|
|||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md'
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
actions
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
|
|
@ -40,7 +46,7 @@ export const Modal = ({
|
|||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
xl: 'max-w-4xl'
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -52,26 +58,31 @@ export const Modal = ({
|
|||
/>
|
||||
|
||||
<div className={`
|
||||
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full
|
||||
${sizeClasses[size]} transform transition-all
|
||||
`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
relative w-full ${sizeClasses[size]}
|
||||
bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
||||
transform transition-all
|
||||
`}>
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<SectionSubtitle>{title}</SectionSubtitle>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{children}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -30,11 +30,31 @@
|
|||
body {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.2s ease;
|
||||
/* Prevent horizontal scrolling on mobile */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
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 {
|
||||
|
|
@ -53,4 +73,76 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
/* Mobile-specific utilities */
|
||||
.mobile-container {
|
||||
@apply px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
@apply card p-4 sm:p-6;
|
||||
}
|
||||
|
||||
/* Safe area for mobile devices */
|
||||
.safe-area {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Mobile-specific form improvements */
|
||||
@media (max-width: 768px) {
|
||||
.input,
|
||||
textarea,
|
||||
select {
|
||||
@apply text-base; /* Prevent zoom on iOS */
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid-cols-1 gap-4;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
@apply flex-col space-y-3 space-x-0;
|
||||
}
|
||||
|
||||
.button-group > * {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive text utilities */
|
||||
.text-responsive-xs {
|
||||
@apply text-xs sm:text-sm;
|
||||
}
|
||||
|
||||
.text-responsive-sm {
|
||||
@apply text-sm sm:text-base;
|
||||
}
|
||||
|
||||
.text-responsive-base {
|
||||
@apply text-base sm:text-lg;
|
||||
}
|
||||
|
||||
.text-responsive-lg {
|
||||
@apply text-lg sm:text-xl lg:text-2xl;
|
||||
}
|
||||
|
||||
.text-responsive-xl {
|
||||
@apply text-xl sm:text-2xl lg:text-3xl;
|
||||
}
|
||||
|
||||
/* Mobile chart container */
|
||||
.chart-container {
|
||||
@apply w-full overflow-hidden;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chart-container {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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 { BarChart } from '../components/charts/BarChart';
|
||||
import { LineChart } from '../components/charts/LineChart';
|
||||
|
|
@ -6,6 +6,7 @@ import { PieChart } from '../components/charts/PieChart';
|
|||
import { Table } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
||||
import { ChartData, TableColumn } from '../types';
|
||||
|
||||
const statsData = [
|
||||
|
|
@ -98,55 +99,69 @@ const userColumns: TableColumn[] = [
|
|||
|
||||
export const Dashboard = () => {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
داشبورد
|
||||
</h1>
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="secondary">
|
||||
گزارشگیری
|
||||
</Button>
|
||||
<PageContainer>
|
||||
{/* Header with mobile-responsive layout */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<PageTitle>داشبورد</PageTitle>
|
||||
<div className="flex justify-start gap-3">
|
||||
<button
|
||||
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="گزارشگیری"
|
||||
>
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<Button>
|
||||
اضافه کردن
|
||||
</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"
|
||||
title="اضافه کردن"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</PermissionWrapper>
|
||||
</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) => (
|
||||
<StatsCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
title="فروش ماهانه"
|
||||
color="#3b82f6"
|
||||
/>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
title="روند رشد"
|
||||
color="#10b981"
|
||||
/>
|
||||
{/* 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
|
||||
data={chartData}
|
||||
title="فروش ماهانه"
|
||||
color="#3b82f6"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
title="روند رشد"
|
||||
color="#10b981"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{/* Table and Pie Chart - Mobile responsive */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
<div className="xl:col-span-2 min-w-0">
|
||||
<div className="card p-3 sm:p-4 lg:p-6">
|
||||
<CardTitle className="mb-3 sm:mb-4">
|
||||
کاربران اخیر
|
||||
</h3>
|
||||
<Table
|
||||
columns={userColumns}
|
||||
data={recentUsers}
|
||||
/>
|
||||
</CardTitle>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={userColumns}
|
||||
data={recentUsers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<PieChart
|
||||
data={pieData}
|
||||
title="دستگاههای کاربری"
|
||||
|
|
@ -154,6 +169,6 @@ export const Dashboard = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
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 { 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 = [
|
||||
{
|
||||
|
|
@ -96,13 +100,13 @@ export const Notifications = () => {
|
|||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||
return <BellOff className="h-5 w-5 text-red-600" />;
|
||||
case 'warning':
|
||||
return <AlertCircle className="h-5 w-5 text-yellow-600" />;
|
||||
return <Bell className="h-5 w-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
return <Bell className="h-5 w-5 text-green-600" />;
|
||||
case 'info':
|
||||
return <Info className="h-5 w-5 text-blue-600" />;
|
||||
return <Eye className="h-5 w-5 text-blue-600" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5 text-gray-600" />;
|
||||
}
|
||||
|
|
@ -156,31 +160,25 @@ export const Notifications = () => {
|
|||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
اعلانات
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
|
||||
</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageTitle>اعلانات</PageTitle>
|
||||
<StatValue>
|
||||
{unreadCount} اعلان خوانده نشده از {notifications.length} اعلان
|
||||
</StatValue>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<Check className="h-4 w-4 ml-2" />
|
||||
همه را خوانده شده علامت بزن
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
اعلان جدید
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<BellOff className="h-4 w-4 ml-2" />
|
||||
همه را خوانده شده علامت بزن
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
اعلان جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
|
|
@ -189,41 +187,41 @@ export const Notifications = () => {
|
|||
<Bell className="h-8 w-8 text-blue-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
|
||||
<StatValue>{notifications.length}</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<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">
|
||||
<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 className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<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">
|
||||
<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}
|
||||
</p>
|
||||
</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<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">
|
||||
<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}
|
||||
</p>
|
||||
</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,7 +306,7 @@ export const Notifications = () => {
|
|||
variant="secondary"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
<BellOff className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -316,7 +314,7 @@ export const Notifications = () => {
|
|||
variant="danger"
|
||||
onClick={() => handleDeleteNotification(notification.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<BellOff className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -339,6 +337,6 @@ export const Notifications = () => {
|
|||
totalItems={filteredNotifications.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
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 { Button } from '../components/ui/Button';
|
||||
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 allOrders = [
|
||||
{ id: 1001, customer: 'علی احمدی', products: '۳ محصول', amount: '۴۵,۰۰۰,۰۰۰', status: 'تحویل شده', date: '۱۴۰۲/۰۸/۱۵' },
|
||||
|
|
@ -100,24 +102,11 @@ export const Orders = () => {
|
|||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
مدیریت سفارشات
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredOrders.length} سفارش یافت شد
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageTitle>مدیریت سفارشات</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredOrders.length} سفارش یافت شد
|
||||
</p>
|
||||
|
||||
<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">
|
||||
|
|
@ -125,19 +114,19 @@ export const Orders = () => {
|
|||
<ShoppingCart className="h-8 w-8 text-blue-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل سفارشات</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allOrders.length}</p>
|
||||
<StatValue>{allOrders.length}</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-8 w-8 text-green-600" />
|
||||
<Package className="h-8 w-8 text-green-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">تحویل شده</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<StatValue>
|
||||
{allOrders.filter(o => o.status === 'تحویل شده').length}
|
||||
</p>
|
||||
</StatValue>
|
||||
</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="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">
|
||||
<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">
|
||||
|
|
@ -198,6 +187,6 @@ export const Orders = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, Search, Filter, Package } from 'lucide-react';
|
||||
import React, { useState } from '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 { Button } from '../components/ui/Button';
|
||||
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 allProducts = [
|
||||
{ id: 1, name: 'لپتاپ ایسوس', category: 'کامپیوتر', price: '۲۵,۰۰۰,۰۰۰', stock: 15, status: 'موجود', createdAt: '۱۴۰۲/۰۸/۱۵' },
|
||||
|
|
@ -103,29 +105,23 @@ const Products = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
مدیریت محصولات
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredProducts.length} محصول یافت شد
|
||||
</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageTitle>مدیریت محصولات</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredProducts.length} محصول یافت شد
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
</Button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<Button onClick={handleAddProduct}>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
افزودن محصول
|
||||
</Button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<Button onClick={handleAddProduct}>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
افزودن محصول
|
||||
</Button>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
|
|
@ -134,7 +130,7 @@ const Products = () => {
|
|||
<Package className="h-8 w-8 text-blue-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل محصولات</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{allProducts.length}</p>
|
||||
<StatValue>{allProducts.length}</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,9 +140,9 @@ const Products = () => {
|
|||
<Package className="h-8 w-8 text-green-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات موجود</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<StatValue>
|
||||
{allProducts.filter(p => p.status === 'موجود').length}
|
||||
</p>
|
||||
</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -156,9 +152,9 @@ const Products = () => {
|
|||
<Package className="h-8 w-8 text-red-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">محصولات ناموجود</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<StatValue>
|
||||
{allProducts.filter(p => p.status === 'ناموجود').length}
|
||||
</p>
|
||||
</StatValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,7 +191,7 @@ const Products = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Button } from '../components/ui/Button';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
|
|
@ -8,6 +8,7 @@ import { UserForm } from '../components/forms/UserForm';
|
|||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { TableColumn } from '../types';
|
||||
import { UserFormData } from '../utils/validationSchemas';
|
||||
import { PageContainer, PageTitle, StatValue } from '../components/ui/Typography';
|
||||
|
||||
const allUsers = [
|
||||
{ id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
|
|
@ -111,27 +112,28 @@ export const Users = () => {
|
|||
};
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
مدیریت کاربران
|
||||
</h1>
|
||||
<PageTitle>مدیریت کاربران</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{filteredUsers.length} کاربر یافت شد
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
</Button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<Button onClick={handleAddUser}>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
افزودن کاربر
|
||||
</Button>
|
||||
<button
|
||||
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"
|
||||
title="افزودن کاربر"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -181,6 +183,6 @@ export const Users = () => {
|
|||
isEdit={!!editingUser}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -176,19 +176,20 @@ const Users = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 ml-2" />
|
||||
فیلتر
|
||||
</Button>
|
||||
<PermissionWrapper permission={25}>
|
||||
<Button
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
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" />
|
||||
افزودن کاربر
|
||||
</Button>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</PermissionWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Input } from "@/components/ui/Input";
|
|||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
const adminUserSchema = yup.object({
|
||||
first_name: yup.string().required('نام الزامی است').min(2, 'نام باید حداقل 2 کاراکتر باشد'),
|
||||
|
|
@ -38,7 +39,7 @@ const AdminUserFormPage = () => {
|
|||
const { data: user, isLoading: isLoadingUser } = useAdminUser(id || '', isEdit);
|
||||
const { mutate: createUser, isPending: isCreating } = useCreateAdminUser();
|
||||
const { mutate: updateUser, isPending: isUpdating } = useUpdateAdminUser();
|
||||
|
||||
|
||||
const { data: permissions, isLoading: isLoadingPermissions } = usePermissions();
|
||||
const { data: roles, isLoading: isLoadingRoles } = useRoles();
|
||||
|
||||
|
|
@ -139,31 +140,28 @@ const AdminUserFormPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</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>
|
||||
<PageContainer className="max-w-2xl mx-auto">
|
||||
<FormHeader
|
||||
title={isEdit ? 'ویرایش کاربر ادمین' : 'ایجاد کاربر ادمین جدید'}
|
||||
subtitle={isEdit ? 'ویرایش اطلاعات کاربر ادمین' : 'اطلاعات کاربر ادمین جدید را وارد کنید'}
|
||||
backButton={backButton}
|
||||
/>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="card p-4 sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input
|
||||
label="نام"
|
||||
|
|
@ -208,7 +206,7 @@ const AdminUserFormPage = () => {
|
|||
isLoading={isLoadingPermissions}
|
||||
error={errors.permissions?.message}
|
||||
/>
|
||||
|
||||
|
||||
<MultiSelectAutocomplete
|
||||
label="نقشها"
|
||||
options={(roles || []).map((role): Option => ({
|
||||
|
|
@ -259,7 +257,7 @@ const AdminUserFormPage = () => {
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
|
|||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Trash2, Edit3, Plus, Eye, Users, UserPlus } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
|
||||
|
||||
// Skeleton Loading Component
|
||||
const AdminUserTableSkeleton = () => (
|
||||
|
|
@ -134,25 +135,28 @@ const AdminUsersListPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageContainer>
|
||||
{/* 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>
|
||||
<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" />
|
||||
مدیریت کاربران ادمین
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
مدیریت کاربران دسترسی به پنل ادمین
|
||||
</p>
|
||||
<PageTitle>مدیریت کاربران ادمین</PageTitle>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">مدیریت کاربران دسترسی به پنل ادمین</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
کاربر ادمین جدید
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
<SectionSubtitle>فیلترها</SectionSubtitle>
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
|
@ -242,8 +246,8 @@ const AdminUsersListPage = () => {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
}`}>
|
||||
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
|
|
@ -297,8 +301,8 @@ const AdminUsersListPage = () => {
|
|||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
|
||||
}`}>
|
||||
{user.status === 'active' ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
|
|
@ -363,7 +367,7 @@ const AdminUsersListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button";
|
|||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
const permissionSchema = yup.object({
|
||||
title: yup.string().required('عنوان الزامی است').min(3, 'عنوان باید حداقل 3 کاراکتر باشد'),
|
||||
|
|
@ -87,26 +88,22 @@ const PermissionFormPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<PageContainer>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</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>
|
||||
<FormHeader
|
||||
title={isEdit ? 'ویرایش دسترسی' : 'ایجاد دسترسی جدید'}
|
||||
subtitle={isEdit ? 'ویرایش اطلاعات دسترسی' : 'اطلاعات دسترسی جدید را وارد کنید'}
|
||||
backButton={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Form */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Label htmlFor="description">
|
||||
توضیحات
|
||||
</label>
|
||||
</Label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
rows={4}
|
||||
|
|
@ -152,7 +149,7 @@ const PermissionFormPage = () => {
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||
import { usePermissions } from '../core/_hooks';
|
||||
import { Permission } from '../core/_models';
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Shield } from "lucide-react";
|
||||
import { Shield, Plus } from "lucide-react";
|
||||
|
||||
// Skeleton Loading Component
|
||||
const PermissionsTableSkeleton = () => (
|
||||
|
|
@ -102,6 +102,13 @@ const PermissionsListPage = () => {
|
|||
نمایش دسترسیهای سیستم
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Button } from "@/components/ui/Button";
|
|||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight, Settings, Plus, Trash2 } from "lucide-react";
|
||||
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
|
||||
|
||||
const maintenanceSchema = yup.object({
|
||||
title: yup.string().required('عنوان نگهداری الزامی است'),
|
||||
|
|
@ -115,26 +116,28 @@ const ProductOptionFormPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/product-options')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
برگشت
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<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
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/product-options')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
برگشت
|
||||
</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">
|
||||
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer className="max-w-4xl mx-auto">
|
||||
<FormHeader
|
||||
title={isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'}
|
||||
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>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
|
||||
|
|
@ -158,7 +161,7 @@ const ProductOptionFormPage = () => {
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<Input
|
||||
label="عنوان نگهداری"
|
||||
|
|
@ -189,7 +192,7 @@ const ProductOptionFormPage = () => {
|
|||
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-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
|
||||
type="button"
|
||||
variant="primary"
|
||||
|
|
@ -265,7 +268,7 @@ const ProductOptionFormPage = () => {
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -100,20 +100,23 @@ const ProductOptionsListPage = () => {
|
|||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Settings className="h-6 w-6" />
|
||||
مدیریت گزینههای محصول
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
مدیریت گزینههایی مثل رنگ، سایز، جنس و غیره
|
||||
تنظیمات گزینههای قابل انتخاب برای محصولات
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
گزینه جدید
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export interface CreateProductRequest {
|
|||
total_sold: number;
|
||||
type: number;
|
||||
attributes?: Record<string, any>;
|
||||
images?: number[];
|
||||
variants?: CreateVariantRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +106,7 @@ export interface UpdateProductRequest {
|
|||
total_sold: number;
|
||||
type: number;
|
||||
attributes?: Record<string, any>;
|
||||
images?: number[];
|
||||
variants?: UpdateVariantRequest[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -15,6 +15,7 @@ import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
|||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { VariantManager } from "@/components/ui/VariantManager";
|
||||
import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react";
|
||||
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
|
||||
|
||||
const productSchema = yup.object({
|
||||
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
|
||||
|
|
@ -67,7 +68,7 @@ const ProductFormPage = () => {
|
|||
design_style: '',
|
||||
enabled: true,
|
||||
total_sold: 0,
|
||||
type: 0,
|
||||
type: 1, // هارد کد شده به VARIABLE
|
||||
category_ids: [],
|
||||
product_option_id: undefined,
|
||||
attributes: {},
|
||||
|
|
@ -80,18 +81,33 @@ const ProductFormPage = () => {
|
|||
|
||||
useEffect(() => {
|
||||
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({
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
design_style: product.design_style || '',
|
||||
enabled: product.enabled,
|
||||
total_sold: product.total_sold || 0,
|
||||
type: product.type || 0,
|
||||
type: 1, // هارد کد شده به VARIABLE
|
||||
category_ids: product.category_ids || [],
|
||||
product_option_id: product.product_option_id || undefined,
|
||||
attributes: product.attributes || {},
|
||||
images: product.images || [],
|
||||
variants: []
|
||||
variants: formVariants
|
||||
});
|
||||
setUploadedImages(product.images || []);
|
||||
setAttributes(product.attributes || {});
|
||||
|
|
@ -147,27 +163,63 @@ const ProductFormPage = () => {
|
|||
};
|
||||
|
||||
const onSubmit = (data: ProductFormData) => {
|
||||
const submitData = {
|
||||
...data,
|
||||
const baseSubmitData = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
design_style: data.design_style,
|
||||
enabled: data.enabled,
|
||||
total_sold: data.total_sold,
|
||||
type: 1, // هارد کد شده به VARIABLE
|
||||
attributes,
|
||||
category_ids: data.category_ids.length > 0 ? data.category_ids : [],
|
||||
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) {
|
||||
// برای 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({
|
||||
id: parseInt(id),
|
||||
...submitData
|
||||
...baseSubmitData,
|
||||
variants: updateVariants
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate('/products');
|
||||
}
|
||||
});
|
||||
} 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: () => {
|
||||
navigate('/products');
|
||||
}
|
||||
|
|
@ -199,28 +251,24 @@ const ProductFormPage = () => {
|
|||
description: `تعداد گزینهها: ${(option.options || []).length}`
|
||||
}));
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
بازگشت
|
||||
</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" />
|
||||
{isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer className="max-w-6xl mx-auto">
|
||||
<FormHeader
|
||||
title={isEdit ? 'ویرایش محصول' : 'ایجاد محصول جدید'}
|
||||
subtitle={isEdit ? 'ویرایش اطلاعات محصول' : 'اطلاعات محصول جدید را وارد کنید'}
|
||||
backButton={backButton}
|
||||
/>
|
||||
|
||||
{/* Form */}
|
||||
<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>
|
||||
</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
|
||||
label="تعداد فروخته شده"
|
||||
|
|
@ -554,7 +585,7 @@ const ProductFormPage = () => {
|
|||
<li>• اولین تصویر به عنوان تصویر اصلی محصول استفاده میشود</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const ProductsListPage = () => {
|
|||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Package className="h-6 w-6" />
|
||||
|
|
@ -153,10 +153,13 @@ const ProductsListPage = () => {
|
|||
مدیریت محصولات، قیمتها و موجودی
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
محصول جدید
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue