From e68320d87185670e7f3a77d5c0902e37faee0947 Mon Sep 17 00:00:00 2001 From: adithiyan Date: Wed, 23 Sep 2026 14:00:06 +0530 Subject: [PATCH] Initial commit for iFixKart Admin --- .env.production | 1 + .gitignore | 8 + .npmrc | 2 + README.md | 36 + SKILL.md | 1238 +++ app/(admin)/activity-logs/page.tsx | 323 + app/(admin)/attributes/page.tsx | 1453 ++++ app/(admin)/brands/page.tsx | 1410 ++++ app/(admin)/categories/page.tsx | 1788 +++++ app/(admin)/customers/page.tsx | 748 ++ app/(admin)/dashboard/mockData.ts | 212 + app/(admin)/dashboard/page.tsx | 865 ++ app/(admin)/devices/page.tsx | 1185 +++ app/(admin)/inventory/page.tsx | 747 ++ app/(admin)/invoices/page.tsx | 457 ++ app/(admin)/layout.tsx | 51 + app/(admin)/loading.tsx | 24 + app/(admin)/migration/page.tsx | 1144 +++ app/(admin)/models/page.tsx | 1669 ++++ app/(admin)/orders/page.tsx | 1411 ++++ app/(admin)/pos-sync/page.tsx | 484 ++ app/(admin)/products/(list)/page.tsx | 2951 +++++++ app/(admin)/products/dashboard/page.tsx | 662 ++ app/(admin)/purchases/page.tsx | 478 ++ app/(admin)/reviews-moderation/page.tsx | 266 + app/(admin)/roles/page.tsx | 520 ++ app/(admin)/security/page.tsx | 790 ++ app/(admin)/series/page.tsx | 1462 ++++ app/(admin)/service-catalog/page.tsx | 962 +++ app/(admin)/service-invoices/page.tsx | 448 ++ app/(admin)/service-quotes/page.tsx | 437 + app/(admin)/services/(list)/page.tsx | 332 + app/(admin)/services/intake/page.tsx | 640 ++ app/(admin)/settings/page.tsx | 756 ++ app/(admin)/staff-directory/page.tsx | 448 ++ app/(admin)/storefront-carousel/page.tsx | 17 + app/(admin)/storefront-cms/page.tsx | 603 ++ app/(admin)/storefront-sections/page.tsx | 2678 +++++++ app/(admin)/technician/page.tsx | 1079 +++ app/(admin)/users/page.tsx | 1542 ++++ app/(auth)/login/page.tsx | 132 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 486 ++ app/layout.tsx | 48 + app/page.tsx | 24 + components/catalog/CreateAttributeModal.tsx | 156 + components/charts/ApexChart.tsx | 48 + components/layouts/Header.tsx | 317 + components/layouts/NavigationProgress.tsx | 62 + components/layouts/Sidebar.tsx | 315 + components/ui/BlurHashImage.tsx | 59 + components/ui/BulkImageUpload.tsx | 134 + components/ui/ConfirmDeleteModal.tsx | 84 + components/ui/CustomSelect.tsx | 113 + components/ui/ImageUpload.tsx | 299 + components/ui/MediaProofModal.tsx | 224 + components/ui/PhoneInput.tsx | 141 + components/ui/RichTextEditor.tsx | 839 ++ components/ui/RowActionsMenu.tsx | 150 + components/ui/SlideOver.tsx | 91 + components/ui/StatsSparklineCard.tsx | 167 + components/ui/TablePagination.tsx | 107 + components/ui/ViewModeToggle.tsx | 45 + eslint.config.mjs | 18 + lib/blurhash.ts | 53 + lib/motion.ts | 4 + lib/utils.ts | 14 + lib/validation.ts | 97 + next-env.d.ts | 6 + next.config.ts | 54 + package-lock.json | 7994 +++++++++++++++++++ package.json | 56 + pnpm-lock.yaml | 5168 ++++++++++++ pnpm-workspace.yaml | 6 + postcss.config.mjs | 7 + providers/AppProviders.tsx | 55 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + scripts/fix_theme_contrast.py | 108 + services/api/adminService.ts | 337 + services/api/authService.ts | 40 + services/api/catalogService.ts | 566 ++ services/api/client.ts | 202 + services/api/config.ts | 131 + services/api/rolePermissionService.ts | 56 + services/api/settingsService.ts | 48 + services/api/userService.ts | 88 + store/uiStore.ts | 135 + tsconfig.json | 34 + tsconfig.tsbuildinfo | 1 + 93 files changed, 51651 insertions(+) create mode 100644 .env.production create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 app/(admin)/activity-logs/page.tsx create mode 100644 app/(admin)/attributes/page.tsx create mode 100644 app/(admin)/brands/page.tsx create mode 100644 app/(admin)/categories/page.tsx create mode 100644 app/(admin)/customers/page.tsx create mode 100644 app/(admin)/dashboard/mockData.ts create mode 100644 app/(admin)/dashboard/page.tsx create mode 100644 app/(admin)/devices/page.tsx create mode 100644 app/(admin)/inventory/page.tsx create mode 100644 app/(admin)/invoices/page.tsx create mode 100644 app/(admin)/layout.tsx create mode 100644 app/(admin)/loading.tsx create mode 100644 app/(admin)/migration/page.tsx create mode 100644 app/(admin)/models/page.tsx create mode 100644 app/(admin)/orders/page.tsx create mode 100644 app/(admin)/pos-sync/page.tsx create mode 100644 app/(admin)/products/(list)/page.tsx create mode 100644 app/(admin)/products/dashboard/page.tsx create mode 100644 app/(admin)/purchases/page.tsx create mode 100644 app/(admin)/reviews-moderation/page.tsx create mode 100644 app/(admin)/roles/page.tsx create mode 100644 app/(admin)/security/page.tsx create mode 100644 app/(admin)/series/page.tsx create mode 100644 app/(admin)/service-catalog/page.tsx create mode 100644 app/(admin)/service-invoices/page.tsx create mode 100644 app/(admin)/service-quotes/page.tsx create mode 100644 app/(admin)/services/(list)/page.tsx create mode 100644 app/(admin)/services/intake/page.tsx create mode 100644 app/(admin)/settings/page.tsx create mode 100644 app/(admin)/staff-directory/page.tsx create mode 100644 app/(admin)/storefront-carousel/page.tsx create mode 100644 app/(admin)/storefront-cms/page.tsx create mode 100644 app/(admin)/storefront-sections/page.tsx create mode 100644 app/(admin)/technician/page.tsx create mode 100644 app/(admin)/users/page.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/catalog/CreateAttributeModal.tsx create mode 100644 components/charts/ApexChart.tsx create mode 100644 components/layouts/Header.tsx create mode 100644 components/layouts/NavigationProgress.tsx create mode 100644 components/layouts/Sidebar.tsx create mode 100644 components/ui/BlurHashImage.tsx create mode 100644 components/ui/BulkImageUpload.tsx create mode 100644 components/ui/ConfirmDeleteModal.tsx create mode 100644 components/ui/CustomSelect.tsx create mode 100644 components/ui/ImageUpload.tsx create mode 100644 components/ui/MediaProofModal.tsx create mode 100644 components/ui/PhoneInput.tsx create mode 100644 components/ui/RichTextEditor.tsx create mode 100644 components/ui/RowActionsMenu.tsx create mode 100644 components/ui/SlideOver.tsx create mode 100644 components/ui/StatsSparklineCard.tsx create mode 100644 components/ui/TablePagination.tsx create mode 100644 components/ui/ViewModeToggle.tsx create mode 100644 eslint.config.mjs create mode 100644 lib/blurhash.ts create mode 100644 lib/motion.ts create mode 100644 lib/utils.ts create mode 100644 lib/validation.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 providers/AppProviders.tsx create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/fix_theme_contrast.py create mode 100644 services/api/adminService.ts create mode 100644 services/api/authService.ts create mode 100644 services/api/catalogService.ts create mode 100644 services/api/client.ts create mode 100644 services/api/config.ts create mode 100644 services/api/rolePermissionService.ts create mode 100644 services/api/settingsService.ts create mode 100644 services/api/userService.ts create mode 100644 store/uiStore.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..3f93c78 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=https://ifixkartbe.trionixsolution.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e120a84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +dist/ +build/ +*.zip +*.tar.gz +*.log +.env.local diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6743151 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Disable pnpm virtual symlinks and force flat, physical folder installation under node_modules for Turbopack compatibility +node-linker=hoisted diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..916180a --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1238 @@ +# CRMS-Inspired Admin Dashboard UI Skill + +## Purpose + +Transform the **existing admin-ecommerce project UI** to follow the visual design language, layout structure, spacing, component patterns, and interaction patterns of the CRMS reference dashboard provided by the user. + +This is a **UI/UX modification skill only**. + +Do **not** rewrite, replace, remove, or alter existing business functionality. + +--- + +# 1. Primary Rule: UI Only + +Before making any changes, analyze the existing codebase and understand the current implementation. + +Preserve all existing: + +* Routes and navigation logic +* API calls +* Backend integration +* Authentication +* Authorization +* Role and permission logic +* React Query logic +* Zustand stores +* React Hook Form logic +* Zod schemas and validation +* Existing TypeScript interfaces and types +* Data models +* CRUD functionality +* Table functionality +* Filtering logic +* Search functionality +* Pagination +* Sorting +* Existing event handlers +* Loading states +* Error states +* Toast notifications +* Existing component props and contracts + +## Important + +Do not change functionality just to make the UI easier to implement. + +The goal is: + +> **Keep the application behavior exactly the same. Improve only the presentation layer.** + +If existing functionality and the new UI conflict, preserve the existing functionality and adapt the UI around it. + +--- + +# 2. Technology Constraints + +The project uses: + +* Next.js 16 +* React 19 +* TypeScript +* Tailwind CSS v4 +* Framer Motion +* Lucide React +* TanStack React Query +* TanStack Table +* TanStack Virtual +* React Hook Form +* Zod +* Zustand +* next-themes +* Recharts +* Sonner + +Use the existing stack. + +Do not introduce Bootstrap, jQuery, another CSS framework, or unnecessary UI libraries. + +Do not add dependencies unless absolutely required. + +Prefer existing utilities and installed packages. + +Use: + +```tsx +lucide-react +``` + +for icons. + +Use: + +```tsx +framer-motion +``` + +only where animation improves the existing UX. + +Use: + +```tsx +recharts +``` + +for existing or new visual presentation of chart data without changing the underlying data source. + +--- + +# 3. Existing Design System Must Be Preserved + +The existing project already has a Tailwind v4 CSS variable design system. + +Do not replace it. + +Do not create a second competing design system. + +Use the existing semantic variables: + +```css +--background +--foreground +--card +--card-foreground +--primary +--primary-foreground +--secondary +--secondary-foreground +--muted +--muted-foreground +--accent +--accent-foreground +--destructive +--border +--input +--ring +--sidebar +--sidebar-foreground +--sidebar-active +--success +--warning +--info +``` + +The existing primary brand color is: + +```text +#e4382f +``` + +This is the application's main accent color. + +Do not replace the brand color with the original CRMS accent color if different. + +Instead: + +> Reproduce the CRMS UI structure and visual hierarchy while applying the existing project color system. + +--- + +# 4. Design Philosophy + +The target UI should feel like a modern, professional SaaS administration dashboard. + +Follow these principles: + +* Clean +* Compact +* Structured +* Information dense without feeling crowded +* Consistent across every page +* Professional +* Responsive +* Reusable +* Minimal unnecessary decoration + +Avoid: + +* Excessive gradients +* Large empty spaces +* Oversized cards +* Excessive border radius +* Glassmorphism +* Heavy shadows +* Random colors +* Huge page headings +* Oversized buttons +* Excessive animations +* Every section looking like an isolated floating widget + +The UI should feel like one coherent admin application. + +--- + +# 5. Application Shell + +Follow this general structure: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Sidebar │ Top Header │ +│ ├────────────────────────────────────────────────────┤ +│ │ Breadcrumb / Page Title / Actions │ +│ ├────────────────────────────────────────────────────┤ +│ │ │ +│ │ Main Page Content │ +│ │ │ +│ │ Cards / Tables / Forms / Charts │ +│ │ │ +└─────────┴────────────────────────────────────────────────────┘ +``` + +The application shell should be reusable. + +Prefer a structure similar to: + +```text +AppLayout + ├── Sidebar + ├── Header + └── MainContent + ├── PageHeader + └── PageContent +``` + +Do not duplicate layout logic across individual pages. + +--- + +# 6. Sidebar Design + +The sidebar should follow a professional CRM/admin pattern. + +## Structure + +Include: + +1. Brand/logo area +2. Main navigation +3. Navigation groups +4. Optional group labels +5. Nested menu support +6. Active item state +7. Expand/collapse behavior where already supported + +## Visual Style + +Use: + +* Compact vertical spacing +* Small but readable icons +* Consistent icon alignment +* Clear hierarchy +* Subtle hover states +* Strong active state +* Group labels with smaller typography +* Smooth submenu transitions + +The sidebar should use: + +```css +var(--sidebar) +``` + +for the main background. + +Use: + +```css +var(--sidebar-foreground) +``` + +for normal navigation text. + +Use: + +```css +var(--sidebar-active) +``` + +for active navigation emphasis. + +Do not hardcode unrelated colors. + +## Active Navigation + +The active item should be immediately recognizable. + +Recommended pattern: + +```text +Active item: +- Brand-colored background or accent indicator +- White/high-contrast icon +- Stronger text weight + +Inactive item: +- Transparent background +- Muted sidebar text +- Subtle hover background +``` + +Avoid large pill-shaped menu items unless the existing application already uses them consistently. + +--- + +# 7. Top Header + +The header should be compact and functional. + +Recommended layout: + +```text +[Sidebar Toggle] [Global Search / Context] + + [Theme] [Notifications] [Profile] +``` + +Use: + +* White or card background +* Bottom border using `--border` +* Compact height +* Horizontally aligned controls +* Icon buttons with consistent dimensions +* Clear spacing between actions + +Do not make the header excessively tall. + +The header should visually separate navigation from application content. + +--- + +# 8. Page Header Pattern + +Every main page should follow a consistent page header. + +Recommended structure: + +```text +Page Title Primary Action +Breadcrumb / Context Secondary Actions +``` + +Example: + +```text +Products +Dashboard / Products + Add Product +``` + +Rules: + +* Keep the page title concise. +* Do not use oversized headings. +* Breadcrumbs should be subtle. +* Actions should align to the right on desktop. +* Stack intelligently on smaller screens. +* Primary actions should use the existing primary color. +* Secondary actions should use outline or neutral styling. + +Avoid wrapping the entire page header in a large unnecessary card. + +The page header should generally be part of the page layout, not a floating container. + +--- + +# 9. Spacing System + +The CRMS-style UI should be compact but comfortable. + +Prefer consistent spacing. + +Use a predictable scale such as: + +```text +4px +8px +12px +16px +20px +24px +32px +``` + +General guidance: + +* Small gap between related controls: `8px` +* Standard component spacing: `12px` or `16px` +* Card padding: `16px` to `24px` +* Major section spacing: `20px` to `24px` + +Avoid large empty areas unless they intentionally improve readability. + +Do not use arbitrary spacing values throughout the project. + +--- + +# 10. Cards + +Cards are a major part of the UI. + +Use the existing: + +```css +--card +--card-foreground +--border +``` + +Recommended card style: + +```text +Background: var(--card) +Border: subtle 1px var(--border) +Radius: 8px to 12px +Shadow: none or extremely subtle +Padding: 16px to 24px +``` + +Cards should not look overly rounded or oversized. + +Avoid: + +```text +rounded-3xl +heavy drop shadows +large padding +huge empty card areas +``` + +Use cards only when they provide meaningful grouping. + +Do not place every small element inside its own card. + +--- + +# 11. Dashboard Widgets + +Dashboard statistics should follow a compact KPI pattern. + +Recommended structure: + +```text +┌──────────────────────────────┐ +│ Label Icon │ +│ │ +│ 1,245 │ +│ +12.5% from last period │ +└──────────────────────────────┘ +``` + +Rules: + +* Label should be smaller and muted. +* Main value should have strong hierarchy. +* Trend indicators should be visually distinct. +* Icons should use subtle background containers. +* Do not make the icon container too large. +* Keep card heights consistent when displayed in a grid. + +Use semantic colors: + +```text +Success → --success +Warning → --warning +Info → --info +Error → --destructive +Primary → --primary +``` + +Do not use random Tailwind color palettes when a semantic variable exists. + +--- + +# 12. Charts and Analytics Sections + +For analytics cards: + +```text +Card Header +├── Title +├── Optional description +└── Filter / Date Range + +Chart Area + +Optional Legend / Summary +``` + +Use Recharts where charts already exist. + +Do not change: + +* API data +* Data transformation logic +* Query logic +* Business calculations + +Only improve: + +* Chart container +* Typography +* Legend +* Tooltip styling +* Layout +* Spacing +* Responsive behavior + +Charts should integrate visually with the design system. + +Avoid excessive grid lines or visual noise. + +--- + +# 13. Tables + +Tables should follow a modern CRM/admin pattern. + +Recommended structure: + +```text +Card +├── Header +│ ├── Title / Record Count +│ └── Actions +│ +├── Toolbar +│ ├── Search +│ ├── Filters +│ └── View / Export / Add actions +│ +├── Table +│ +└── Pagination +``` + +## Table Style + +Use: + +* Compact row height +* Clear column alignment +* Muted table header +* Subtle row separators +* Soft hover state +* Sticky header only when useful +* Horizontal scrolling on smaller screens + +Avoid excessive borders around every cell. + +Prefer horizontal row separation. + +Example hierarchy: + +```text +Header: +- Smaller +- Muted +- Semibold + +Cell: +- Normal readable text + +Primary entity: +- Stronger text + +Secondary information: +- Muted text +``` + +Do not change TanStack Table logic. + +Only modify the presentation layer. + +--- + +# 14. Search and Filter Toolbars + +Avoid large search cards with too many buttons. + +Instead, create a compact responsive toolbar. + +Desktop: + +```text +[Search Input] [Filter] [Sort] [More] [Primary Action] +``` + +Smaller screens: + +```text +[Search Input ] +[Filter] [Sort] [More] [Add] +``` + +Rules: + +* Search input should be the largest flexible element. +* Secondary controls should be icon buttons or compact buttons when appropriate. +* Group related controls. +* Hide low-priority actions inside a dropdown when space is limited. +* Do not allow controls to consume excessive vertical space. + +Use existing functionality. + +Do not remove filters or actions. + +If necessary, move existing actions into a dropdown or overflow menu while preserving access. + +--- + +# 15. Buttons + +Create a consistent button hierarchy. + +## Primary + +Use for the main action: + +```text +Background: --primary +Text: --primary-foreground +``` + +Examples: + +```text +Add Product +Create Order +Save Changes +``` + +## Secondary + +Use for non-primary actions. + +Recommended: + +```text +Neutral/card background +Border +Foreground text +``` + +## Icon Buttons + +Use for: + +* Filter +* More actions +* Edit +* Delete +* Refresh +* Settings +* Notifications + +Keep icon buttons consistent in size. + +Do not create different button styles on every page. + +--- + +# 16. Forms + +Forms should be visually compact and structured. + +Use a consistent field pattern: + +```text +Label +Input / Select / Control +Helper text or Validation message +``` + +Rules: + +* Keep labels above fields. +* Use consistent control heights. +* Align related fields in responsive grids. +* Avoid excessive nesting. +* Avoid placing each field inside a separate card. +* Group fields only when there is a meaningful section. + +Recommended: + +```text +Desktop: +2 or 3 column responsive grid + +Mobile: +1 column +``` + +Do not change: + +* React Hook Form +* Zod validation +* Field names +* Submit handlers +* Form submission logic + +Only change the visual layout. + +--- + +# 17. Modals and Right-Side Drawers + +For create/edit experiences, prefer the interaction pattern already used by the application. + +When a right-side panel is appropriate: + +```text +Closed +↓ +Smooth slide from right +↓ +Form content +↓ +Save +↓ +Success +↓ +Close +``` + +Use Framer Motion only if the project does not already have an existing animation implementation. + +Keep transitions subtle: + +```text +Duration: approximately 200ms–300ms +Ease: smooth +``` + +Do not introduce heavy animations. + +Do not alter submit behavior. + +After successful submission, preserve the existing success and refresh behavior. + +--- + +# 18. Dropdowns and Action Menus + +Use compact action menus for secondary actions. + +Typical actions: + +```text +Edit +Duplicate +Archive +Delete +``` + +Destructive actions must remain visually distinguishable. + +Use: + +```css +--destructive +``` + +Do not expose every action as a visible button when space is limited. + +--- + +# 19. Badges and Status + +Status indicators should be compact and easy to scan. + +Recommended statuses: + +```text +Active +Inactive +Pending +Completed +Cancelled +Draft +Published +``` + +Use semantic colors and subtle backgrounds. + +Example: + +```text +Success → green semantic styling +Warning → amber semantic styling +Info → info semantic styling +Destructive → red semantic styling +``` + +Badges should not dominate the interface. + +Avoid overly large pills. + +--- + +# 20. Icons + +Use `lucide-react`. + +Rules: + +* Use consistent stroke widths. +* Use consistent icon sizes. +* Standard interface icons: approximately 16px–20px. +* Larger dashboard icons: approximately 20px–24px. +* Do not mix multiple icon libraries. +* Do not use emoji as UI icons. + +Icons must align correctly with text. + +--- + +# 21. Typography + +Maintain a clear hierarchy. + +Recommended hierarchy: + +```text +Page Title +20px–24px +Semibold / Bold + +Section Title +16px–18px +Semibold + +Card Label +12px–14px +Medium / Semibold + +Body +13px–15px +Normal + +Secondary Text +12px–14px +Muted +``` + +Do not use extremely large typography for normal dashboard pages. + +Use: + +```css +var(--foreground) +``` + +for primary text. + +Use: + +```css +var(--muted-foreground) +``` + +for secondary information. + +--- + +# 22. Light and Dark Mode + +The existing dark mode system must continue working. + +Do not: + +* Remove `.dark` overrides. +* Replace CSS variables with hardcoded light colors. +* Add components that only work in light mode. + +Every UI change must be tested in: + +```text +Light Mode +Dark Mode +``` + +When adding new styles: + +Prefer: + +```tsx +bg-card +text-foreground +border-border +bg-muted +text-muted-foreground +bg-primary +``` + +Avoid: + +```tsx +bg-white +text-slate-900 +border-gray-200 +``` + +unless the existing global dark mode override intentionally supports that usage. + +The preferred approach is to use semantic design tokens directly. + +--- + +# 23. Responsive Rules + +The UI must work across: + +```text +Desktop +Laptop +Tablet +Mobile +``` + +Recommended behavior: + +## Desktop + +* Full sidebar +* Full table layout +* Inline actions +* Multi-column forms + +## Tablet + +* Adaptive sidebar +* Reduced spacing +* Wrapping page actions +* Responsive card grids + +## Mobile + +* Overlay sidebar +* Single-column layout +* Horizontally scrollable tables +* Compact action controls +* Stacked forms +* Full-width primary actions where appropriate + +Do not simply shrink the desktop UI. + +Reorganize controls intelligently. + +--- + +# 24. Animation Rules + +Animations should feel subtle and functional. + +Allowed: + +* Sidebar transition +* Dropdown fade/scale +* Drawer slide +* Modal appearance +* Small hover transitions +* Card interaction feedback + +Avoid: + +* Constant animation +* Bouncing elements +* Large entrance animations +* Excessive motion +* Long animation durations + +Respect: + +```css +prefers-reduced-motion +``` + +The existing reduced motion implementation must remain intact. + +--- + +# 25. Component Reusability + +Before creating a new UI component: + +1. Search the existing project. +2. Check whether a similar component already exists. +3. Reuse or improve the existing component when possible. +4. Create a reusable component only when it will be used in multiple places. + +Useful reusable UI patterns include: + +```text +AppLayout +SidebarNavItem +SidebarGroup +PageHeader +PageActionBar +StatCard +SectionCard +TableToolbar +StatusBadge +EmptyState +LoadingState +IconButton +ConfirmDialog +SlideOver +``` + +Do not create duplicate versions of the same UI pattern. + +--- + +# 26. CSS Rules + +Modify the existing project CSS carefully. + +Do not rewrite the entire stylesheet unless necessary. + +Do not remove existing: + +* Theme variables +* Dark mode support +* Accessibility styles +* Scrollbar styles +* Reduced motion support + +When additional reusable styles are needed: + +* Add them in a clearly organized section. +* Use existing CSS variables. +* Avoid global selectors that can unexpectedly affect unrelated pages. +* Avoid `!important` unless working around an unavoidable existing conflict. + +Prefer component-level Tailwind classes for page-specific styling. + +Use global CSS only for genuinely global patterns. + +--- + +# 27. Existing Global CSS + +The existing CSS design system is authoritative. + +Specifically preserve: + +```css +--background: #f4f7fe; +--foreground: #1b2559; +--card: #ffffff; +--primary: #e4382f; +--sidebar: #1b2559; +--success: #01b574; +--warning: #ffb547; +--info: #4318ff; +``` + +Preserve the corresponding dark mode variables. + +The CRMS-inspired UI should therefore use: + +> CRMS layout and component patterns + the existing application's color system. + +Do not blindly copy colors from the reference. + +--- + +# 28. Implementation Workflow + +Follow this workflow for every UI modification. + +## Step 1: Analyze First + +Before editing: + +* Inspect the relevant page. +* Inspect its parent layout. +* Inspect reusable components. +* Identify existing functionality. +* Identify existing data flow. +* Identify state and event handlers. +* Identify existing responsive behavior. + +Do not start rewriting JSX before understanding the component. + +## Step 2: Define UI Changes + +Determine: + +```text +What can change: +- Layout +- Tailwind classes +- Spacing +- Typography +- Component composition +- Icon placement +- Responsive structure +- Visual hierarchy + +What must not change: +- Logic +- Data +- APIs +- State +- Validation +- Permissions +- Business rules +``` + +## Step 3: Reuse + +Use existing components wherever possible. + +## Step 4: Implement + +Modify only the required UI layer. + +## Step 5: Verify + +After implementation, verify: + +```text +- Existing page still renders +- No TypeScript errors +- No broken imports +- No changed API behavior +- Existing forms still submit +- Existing permissions still work +- Existing table behavior still works +- Light mode works +- Dark mode works +- Desktop works +- Mobile works +``` + +--- + +# 29. Safety Rules for Code Changes + +Never perform broad refactoring when the task is only UI-related. + +Do not: + +* Rename API fields +* Rename form fields +* Change Zod schemas +* Change API endpoints +* Change database models +* Replace state management +* Rewrite working hooks +* Change permission checks +* Remove loading/error states +* Replace working table logic +* Modify route behavior + +If JSX is difficult to restyle because logic and presentation are mixed: + +1. Extract presentation components carefully. +2. Keep existing logic intact. +3. Pass the same props and handlers. +4. Verify behavior remains unchanged. + +--- + +# 30. Code Quality + +Follow these rules: + +* TypeScript must remain valid. +* Avoid `any`. +* Avoid unused imports. +* Avoid duplicate components. +* Avoid dead CSS. +* Avoid hardcoded repeated values when a semantic variable exists. +* Preserve existing naming conventions. +* Keep components readable. +* Do not over-engineer. + +Run or verify: + +```bash +npm run lint +npm run build +``` + +Fix UI-related TypeScript or lint issues introduced by the modification. + +Do not make unrelated code changes merely to clean up the project. + +--- + +# 31. Visual Checklist + +Before considering a page complete, verify: + +* [ ] Page follows the shared application shell. +* [ ] Sidebar matches the CRMS-inspired navigation pattern. +* [ ] Header is compact and consistent. +* [ ] Page title and actions are aligned correctly. +* [ ] Excessive empty space has been removed. +* [ ] Cards use consistent padding and radius. +* [ ] Search and filters are compact. +* [ ] Tables are clean and readable. +* [ ] Actions do not consume unnecessary space. +* [ ] Buttons follow a consistent hierarchy. +* [ ] Icons come from Lucide React. +* [ ] Status colors use semantic variables. +* [ ] Existing primary red color is preserved. +* [ ] Light mode works. +* [ ] Dark mode works. +* [ ] Mobile layout works. +* [ ] Existing functionality remains unchanged. +* [ ] No unnecessary dependencies were added. +* [ ] No unrelated files were modified. + +--- + +# 32. Final Instruction + +When asked to modify any page in this project: + +1. First inspect the existing implementation. +2. Preserve all functionality. +3. Modify only the UI and presentation layer. +4. Follow the CRMS-inspired admin dashboard design language. +5. Use the existing Tailwind v4 CSS variable design system. +6. Preserve the existing primary color and dark mode. +7. Reuse existing components before creating new ones. +8. Keep the interface compact, professional, and responsive. +9. Do not introduce Bootstrap or another UI framework. +10. Do not perform unrelated refactoring. +11. Verify that the application behavior remains exactly the same. + +The final result should feel like: + +> **A polished, compact, modern CRM/admin dashboard inspired by the provided CRMS reference, while remaining visually branded with the existing project's design tokens and preserving 100% of the current application functionality.** diff --git a/app/(admin)/activity-logs/page.tsx b/app/(admin)/activity-logs/page.tsx new file mode 100644 index 0000000..dba6431 --- /dev/null +++ b/app/(admin)/activity-logs/page.tsx @@ -0,0 +1,323 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Search, Filter, RefreshCw, Loader2, ChevronDown, Download, FileSpreadsheet } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; +import { adminService } from '@/services/api/adminService'; +import { CustomSelect } from '@/components/ui/CustomSelect'; +import { TablePagination, useClientPagination } from '@/components/ui/TablePagination'; + +type ActivityAction = 'Create' | 'Update' | 'Delete' | 'Export' | 'Failed Login'; + +interface MergedActivityLog { + logId: string; + auditId: string; + user: { + name: string; + avatarInitials: string; + }; + action: ActivityAction; + module: string; + recordId: string; + actionDate: string; + ipAddress: string; +} + +function formatAction(action: string): ActivityAction { + if (action === 'failed_login') return 'Failed Login'; + const label = action.charAt(0).toUpperCase() + action.slice(1).toLowerCase(); + if (label === 'Create' || label === 'Update' || label === 'Delete' || label === 'Export') return label; + return 'Update'; +} + +function actionBadgeClass(action: string) { + const act = action.toLowerCase(); + if (act === 'create') return 'bg-success text-white'; + if (act === 'update') return 'bg-primary text-white'; + if (act === 'delete') return 'bg-destructive text-white'; + if (act === 'failed login') return 'bg-warning text-white'; + return 'bg-muted text-muted-foreground'; +} + +export default function ActivityLogsPage() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [actionFilter, setActionFilter] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [draftAction, setDraftAction] = useState(''); + const filterRef = useRef(null); + + const fetchLogs = async () => { + setLoading(true); + try { + const res = await adminService.getAuditLogs(100); + const backendLogs: MergedActivityLog[] = res.map((item) => ({ + logId: item.audit_id.substring(0, 8).toUpperCase(), + auditId: item.audit_id, + user: { + name: item.user?.name || 'System / Guest', + avatarInitials: item.user?.avatarInitials || 'SYS', + }, + action: formatAction(item.action), + module: item.entity_type, + recordId: item.entity_id, + actionDate: item.created_at, + ipAddress: item.ip_address, + })); + setLogs(backendLogs); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load activity logs'; + toast.error(message); + setLogs([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLogs(); + }, []); + + useEffect(() => { + if (!showFilters) return; + const onPointerDown = (event: MouseEvent) => { + if (!filterRef.current?.contains(event.target as Node)) setShowFilters(false); + }; + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [showFilters]); + + const filteredLogs = useMemo(() => { + const q = search.trim().toLowerCase(); + return logs.filter((log) => { + const matchesSearch = + !q || + log.user.name.toLowerCase().includes(q) || + log.module.toLowerCase().includes(q) || + log.logId.toLowerCase().includes(q) || + log.recordId.toLowerCase().includes(q); + const matchesAction = actionFilter ? log.action === actionFilter : true; + return matchesSearch && matchesAction; + }); + }, [logs, search, actionFilter]); + + const pager = useClientPagination(filteredLogs); + + const filtersActive = Boolean(actionFilter); + + const handleExportCSV = () => { + const headers = ['Log ID', 'User', 'Action', 'Module', 'Record ID', 'Action Date', 'IP Address']; + const rows = filteredLogs.map((log) => [ + log.logId, + log.user.name, + log.action, + log.module, + log.recordId, + format(new Date(log.actionDate), 'dd MMM yyyy, hh:mm a'), + log.ipAddress, + ]); + const csv = [headers, ...rows] + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'user-activity-logs.csv'; + link.click(); + URL.revokeObjectURL(url); + }; + + const dataCardShell = + 'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0'; + const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5'; + const primaryButton = + 'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50'; + const secondaryButton = + 'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50'; + + return ( +
+
+
+
+

User Activity Logs

+ + {logs.length} + +
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ + {showFilters && ( +
+
+ + +
+
+ + +
+
+ )} +
+ +
+ + setSearch(e.target.value)} + className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+
+
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredLogs.length === 0 ? ( + + + + ) : ( + pager.items.map((log) => ( + + + + + + + + + + )) + )} + +
Log IDUserActionModuleRecord IDAction dateIP address
+ +

Loading activity logs...

+
+ {search || actionFilter ? 'No logs match your search' : 'No activity logs recorded'} +
{log.logId} +
+
+ {log.user.avatarInitials} +
+ {log.user.name} +
+
+ + {log.action} + + + {log.module} + + {log.recordId} + + {format(new Date(log.actionDate), 'dd MMM yyyy, hh:mm a')} + {log.ipAddress}
+
+ +
+
+ ); +} diff --git a/app/(admin)/attributes/page.tsx b/app/(admin)/attributes/page.tsx new file mode 100644 index 0000000..3aba937 --- /dev/null +++ b/app/(admin)/attributes/page.tsx @@ -0,0 +1,1453 @@ +'use client'; + +import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { + Sliders, + Plus, + Search, + RefreshCw, + ChevronDown, + ChevronRight, + Trash2, + Filter, + X, + Box, + FileSpreadsheet, + FileText, + Columns3, + GripVertical, + ArrowUpDown, + Calendar, + AlertTriangle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { format, subDays } from 'date-fns'; +import { AnimatePresence, motion } from '@/lib/motion'; +import { catalogService, AttributeTypeResponse } from '@/services/api/catalogService'; +import { SlideOver } from '@/components/ui/SlideOver'; +import { RowActionsMenu } from '@/components/ui/RowActionsMenu'; +import { ViewModeToggle } from '@/components/ui/ViewModeToggle'; +import { TablePagination, useClientPagination } from '@/components/ui/TablePagination'; + +type StatusFilter = 'active' | 'inactive'; +type PresetFilter = 'with' | 'without'; +type SortField = 'name' | 'code' | 'presets' | 'status'; +type SortDir = 'asc' | 'desc'; +type SortConfig = { field: SortField; dir: SortDir }; +type FilterSection = 'name' | 'presets' | 'status'; + +type AttributeFilters = { + attributeIds: string[]; + presets: PresetFilter[]; + statuses: StatusFilter[]; +}; + +const EMPTY_FILTERS: AttributeFilters = { + attributeIds: [], + presets: [], + statuses: [], +}; + +const FILTER_PAGE_SIZE = 5; + +const EMPTY_COLUMNS = { + attributeName: true, + code: true, + presetValues: true, + status: true, + actions: true, +}; + +const COLUMN_OPTIONS = [ + { key: 'attributeName' as const, label: 'Attribute Name', locked: true }, + { key: 'code' as const, label: 'System Code', locked: false }, + { key: 'presetValues' as const, label: 'Preset Values', locked: false }, + { key: 'status' as const, label: 'Status', locked: false }, + { key: 'actions' as const, label: 'Actions', locked: false }, +]; + +const SORT_OPTIONS: { field: SortField; dir: SortDir; label: string }[] = [ + { field: 'name', dir: 'asc', label: 'Name A-Z' }, + { field: 'name', dir: 'desc', label: 'Name Z-A' }, + { field: 'code', dir: 'asc', label: 'Code A-Z' }, + { field: 'code', dir: 'desc', label: 'Code Z-A' }, + { field: 'presets', dir: 'asc', label: 'Preset Count Ascending' }, + { field: 'presets', dir: 'desc', label: 'Preset Count Descending' }, + { field: 'status', dir: 'asc', label: 'Status Active first' }, + { field: 'status', dir: 'desc', label: 'Status Inactive first' }, +]; + +const DEFAULT_SORT: SortConfig = { field: 'name', dir: 'asc' }; +const DEFAULT_DATE_FROM = format(subDays(new Date(), 30), 'yyyy-MM-dd'); +const DEFAULT_DATE_TO = format(new Date(), 'yyyy-MM-dd'); + +function normalizeId(value: unknown): string { + if (value == null) return ''; + return String(value).trim(); +} + +function coerceFlag(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (value == null) return false; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'enabled', 'active'].includes(normalized)) return true; + if (['false', '0', 'no', 'disabled', 'inactive', '', 'null', 'undefined'].includes(normalized)) return false; + return false; +} + +function isAttributeActive(status?: string): boolean { + if (status == null || String(status).trim() === '') return true; + return coerceFlag(status); +} + +function presetList(attribute: AttributeTypeResponse): string[] { + return (attribute.preset_values || []).filter((value) => String(value).trim() !== ''); +} + +function dayStartMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); +} + +function dayEndMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 23, 59, 59, 999).getTime(); +} + +export default function AttributesPage() { + const [attributes, setAttributes] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'table' | 'grid'>('table'); + const [openActionId, setOpenActionId] = useState(null); + const [showFilterPanel, setShowFilterPanel] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [showColumnsPanel, setShowColumnsPanel] = useState(false); + const [showSortPanel, setShowSortPanel] = useState(false); + const [showDatePanel, setShowDatePanel] = useState(false); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [activeFilters, setActiveFilters] = useState(EMPTY_FILTERS); + const [expandedFilterSections, setExpandedFilterSections] = useState>({ + name: false, + presets: true, + status: false, + }); + const [filterSectionSearch, setFilterSectionSearch] = useState({ name: '' }); + const [filterVisibleCounts, setFilterVisibleCounts] = useState({ name: FILTER_PAGE_SIZE }); + const [visibleColumns, setVisibleColumns] = useState(EMPTY_COLUMNS); + const [sortConfig, setSortConfig] = useState(DEFAULT_SORT); + const [dateFrom, setDateFrom] = useState(DEFAULT_DATE_FROM); + const [dateTo, setDateTo] = useState(DEFAULT_DATE_TO); + const [draftDateFrom, setDraftDateFrom] = useState(DEFAULT_DATE_FROM); + const [draftDateTo, setDraftDateTo] = useState(DEFAULT_DATE_TO); + const [dateFilterEnabled, setDateFilterEnabled] = useState(false); + const filterRef = useRef(null); + const exportRef = useRef(null); + const columnsRef = useRef(null); + const sortRef = useRef(null); + const dateRef = useRef(null); + + const [showAttributeModal, setShowAttributeModal] = useState(false); + const [editingAttribute, setEditingAttribute] = useState(null); + const [attrName, setAttrName] = useState(''); + const [attrCode, setAttrCode] = useState(''); + const [presetValues, setPresetValues] = useState([]); + const [newPresetInput, setNewPresetInput] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchData = async () => { + setLoading(true); + try { + const fetchedAttrs = await catalogService.getAttributes(); + setAttributes(fetchedAttrs); + } catch (err: any) { + toast.error(err?.message || 'Failed to load master attributes'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + if (!showFilterPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!filterRef.current?.contains(event.target as Node)) setShowFilterPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowFilterPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showFilterPanel]); + + useEffect(() => { + if (!showExportMenu) return; + const onPointerDown = (event: MouseEvent) => { + if (!exportRef.current?.contains(event.target as Node)) setShowExportMenu(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowExportMenu(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showExportMenu]); + + useEffect(() => { + if (!showColumnsPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!columnsRef.current?.contains(event.target as Node)) setShowColumnsPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowColumnsPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showColumnsPanel]); + + useEffect(() => { + if (!showSortPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!sortRef.current?.contains(event.target as Node)) setShowSortPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowSortPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showSortPanel]); + + useEffect(() => { + if (!showDatePanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!dateRef.current?.contains(event.target as Node)) setShowDatePanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowDatePanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showDatePanel]); + + const closeOverlays = () => { + setShowFilterPanel(false); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + }; + + const handleViewModeChange = (mode: 'table' | 'grid') => { + setViewMode(mode); + setOpenActionId(null); + closeOverlays(); + }; + + const handleOpenCreate = () => { + setEditingAttribute(null); + setAttrName(''); + setAttrCode(''); + setPresetValues([]); + setNewPresetInput(''); + setShowAttributeModal(true); + }; + + const handleOpenEdit = (attribute: AttributeTypeResponse) => { + setEditingAttribute(attribute); + setAttrName(attribute.name); + setAttrCode(attribute.code); + setPresetValues(attribute.preset_values || []); + setNewPresetInput(''); + setShowAttributeModal(true); + }; + + const closeAttributeDrawer = () => { + if (isSubmitting) return; + setShowAttributeModal(false); + }; + + const handleAddPresetValue = () => { + const val = newPresetInput.trim(); + if (!val) return; + if (presetValues.some((item) => item.toLowerCase() === val.toLowerCase())) { + toast.error('Value already added'); + return; + } + setPresetValues([...presetValues, val]); + setNewPresetInput(''); + }; + + const handleRemovePresetValue = (idx: number) => { + setPresetValues(presetValues.filter((_, i) => i !== idx)); + }; + + const handleSubmitAttribute = async (e: React.FormEvent) => { + e.preventDefault(); + if (!attrName.trim() || !attrCode.trim()) { + toast.error('Both Attribute Name and System Code are required'); + return; + } + setIsSubmitting(true); + try { + if (editingAttribute) { + const updated = await catalogService.updateAttribute(editingAttribute.attribute_id, { + name: attrName.trim(), + code: attrCode.trim().toLowerCase(), + preset_values: presetValues, + }); + setAttributes(attributes.map((a) => (a.attribute_id === updated.attribute_id ? updated : a))); + toast.success(`Attribute "${updated.name}" updated successfully`); + } else { + const created = await catalogService.createAttribute({ + name: attrName.trim(), + code: attrCode.trim().toLowerCase(), + preset_values: presetValues, + }); + setAttributes([...attributes, created]); + toast.success(`Attribute "${created.name}" created successfully`); + } + setAttrName(''); + setAttrCode(''); + setPresetValues([]); + setNewPresetInput(''); + setEditingAttribute(null); + setShowAttributeModal(false); + } catch (err: any) { + toast.error(err?.message || `Failed to ${editingAttribute ? 'update' : 'create'} attribute`); + } finally { + setIsSubmitting(false); + } + }; + + const openDeleteModal = (id: string, name: string) => { + setOpenActionId(null); + setDeleteTarget({ id, name }); + }; + + const closeDeleteModal = () => { + if (isDeleting) return; + setDeleteTarget(null); + }; + + const handleConfirmDelete = async () => { + if (!deleteTarget) return; + const { id, name } = deleteTarget; + setIsDeleting(true); + try { + setAttributes((prev) => prev.filter((a) => a.attribute_id !== id)); + await catalogService.deleteAttribute(id); + toast.success(`Master attribute "${name}" deleted successfully`); + setDeleteTarget(null); + fetchData(); + } catch (err: any) { + toast.error(err?.message || 'Failed to delete master attribute'); + fetchData(); + } finally { + setIsDeleting(false); + } + }; + + const filteredAttributes = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const fromTs = dateFilterEnabled && dateFrom ? dayStartMs(dateFrom) : null; + const toTs = dateFilterEnabled && dateTo ? dayEndMs(dateTo) : null; + + const result = attributes.filter((a) => { + const values = presetList(a); + const searchMatch = + !q || + (a.name || '').toLowerCase().includes(q) || + (a.code || '').toLowerCase().includes(q) || + values.some((value) => value.toLowerCase().includes(q)); + + const isActive = isAttributeActive(a.status); + const statusMatch = + activeFilters.statuses.length === 0 || + (activeFilters.statuses.includes('active') && isActive) || + (activeFilters.statuses.includes('inactive') && !isActive); + + const attributeMatch = + activeFilters.attributeIds.length === 0 || + activeFilters.attributeIds.includes(normalizeId(a.attribute_id)); + + const hasPresets = values.length > 0; + const presetMatch = + activeFilters.presets.length === 0 || + (activeFilters.presets.includes('with') && hasPresets) || + (activeFilters.presets.includes('without') && !hasPresets); + + let dateMatch = true; + if (fromTs != null && toTs != null && !Number.isNaN(fromTs) && !Number.isNaN(toTs) && a.created_at) { + const createdTs = new Date(a.created_at).getTime(); + if (!Number.isNaN(createdTs)) { + dateMatch = createdTs >= fromTs && createdTs <= toTs; + } + } + + return searchMatch && attributeMatch && statusMatch && presetMatch && dateMatch; + }); + + const dir = sortConfig.dir === 'asc' ? 1 : -1; + return [...result].sort((a, b) => { + if (sortConfig.field === 'status') { + return (Number(isAttributeActive(a.status)) - Number(isAttributeActive(b.status))) * dir; + } + if (sortConfig.field === 'presets') { + return (presetList(a).length - presetList(b).length) * dir; + } + const left = sortConfig.field === 'code' ? a.code || '' : a.name || ''; + const right = sortConfig.field === 'code' ? b.code || '' : b.name || ''; + return left.localeCompare(right, undefined, { sensitivity: 'base' }) * dir; + }); + }, [attributes, searchQuery, activeFilters, dateFrom, dateTo, dateFilterEnabled, sortConfig]); + + const pager = useClientPagination(filteredAttributes); + + const filtersActive = + activeFilters.attributeIds.length > 0 || + activeFilters.presets.length > 0 || + activeFilters.statuses.length > 0; + + const sortedAttributeFilterOptions = useMemo( + () => [...attributes].sort((a, b) => a.name.localeCompare(b.name)), + [attributes] + ); + + const toggleFilterSection = (section: FilterSection) => { + setExpandedFilterSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + + const toggleDraftListValue = ( + key: keyof Pick, + value: T + ) => { + setDraftFilters((prev) => { + const current = prev[key] as T[]; + return { + ...prev, + [key]: current.includes(value) ? current.filter((item) => item !== value) : [...current, value], + }; + }); + }; + + const openFilterPanel = () => { + setDraftFilters(activeFilters); + setFilterSectionSearch({ name: '' }); + setFilterVisibleCounts({ name: FILTER_PAGE_SIZE }); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + setShowFilterPanel(true); + }; + + const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length; + + const toggleColumn = (key: keyof typeof EMPTY_COLUMNS) => { + if (key === 'attributeName') return; + setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleExportExcel = () => { + if (filteredAttributes.length === 0) { + toast.warning('No attributes to export'); + return; + } + const headers = ['S.No', 'Attribute Name', 'System Code', 'Preset Values', 'Status']; + const rows = filteredAttributes.map((a, i) => [ + i + 1, + a.name, + a.code || '', + presetList(a).join(', ') || 'None', + isAttributeActive(a.status) ? 'Active' : 'Inactive', + ]); + const csvContent = [headers.join(','), ...rows.map((row) => row.map((val) => `"${String(val).replace(/"/g, '""')}"`).join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `ifixkart_attributes_${format(new Date(), 'yyyyMMdd')}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + setShowExportMenu(false); + toast.success(`Downloaded ${filteredAttributes.length} ${filteredAttributes.length === 1 ? 'attribute' : 'attributes'} as CSV`); + }; + + const handleExportPDF = () => { + if (filteredAttributes.length === 0) { + toast.warning('No attributes to export'); + return; + } + const printWindow = window.open('', '_blank'); + if (!printWindow) { + toast.error('Allow pop-ups to open the attributes print preview'); + return; + } + const html = ` + + + iFixKart Master Attributes PDF Export + + + +

iFixKart Master Attributes (${filteredAttributes.length} Records)

+

Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}

+ + + + + + + + + + + + ${filteredAttributes.map((a, i) => ` + + + + + + + + `).join('')} + +
S.NoAttribute NameSystem CodePreset ValuesStatus
${i + 1}${a.name}${a.code || ''}${presetList(a).join(', ') || 'None'}${isAttributeActive(a.status) ? 'Active' : 'Inactive'}
+ + + + `; + printWindow.document.write(html); + printWindow.document.close(); + setShowExportMenu(false); + toast.success(`Print preview opened for ${filteredAttributes.length} ${filteredAttributes.length === 1 ? 'attribute' : 'attributes'}`); + }; + + const dataCardShell = 'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0'; + + const renderSortLabel = (label: string, field?: SortField, showIcon = true) => { + const active = field != null && sortConfig.field === field; + return ( + + {label} + {showIcon && ( + + )} + + ); + }; + + const renderStatusBadge = (active: boolean) => ( + + {active ? 'Active' : 'Inactive'} + + ); + + const renderPresetBadges = (values: string[], limit?: number) => { + if (values.length === 0) { + return No preset values; + } + const shown = limit != null ? values.slice(0, limit) : values; + const remaining = limit != null ? Math.max(values.length - shown.length, 0) : 0; + return ( +
+ {shown.map((value) => ( + + {value} + + ))} + {remaining > 0 && ( + + +{remaining} + + )} +
+ ); + }; + + const searchField = ( +
+ + setSearchQuery(e.target.value)} + className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ ); + + const addAttributeButton = ( + + ); + + const viewToggle = ; + + const renderFilterControl = (align: 'left' | 'right') => { + const nameQuery = filterSectionSearch.name.trim().toLowerCase(); + const filteredNameOptions = sortedAttributeFilterOptions.filter( + (attribute) => + !nameQuery || + attribute.name.toLowerCase().includes(nameQuery) || + (attribute.code || '').toLowerCase().includes(nameQuery) + ); + const visibleNameOptions = filteredNameOptions.slice(0, filterVisibleCounts.name); + + const renderSectionSearch = (value: string, onChange: (next: string) => void) => ( +
+ + { + onChange(e.target.value); + setFilterVisibleCounts({ name: FILTER_PAGE_SIZE }); + }} + className="w-full h-8 pl-8 pr-3 crm-radius-control border border-border bg-card text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + /> +
+ ); + + const renderCheckboxOption = (checked: boolean, onToggle: () => void, label: string, icon?: ReactNode) => ( + + ); + + return ( +
+ + + {showFilterPanel && ( + +
+ + + Filter + + +
+ +
+
+ + {expandedFilterSections.name && ( +
+
+ {renderSectionSearch(filterSectionSearch.name, (value) => + setFilterSectionSearch({ name: value }) + )} +
+ {visibleNameOptions.length === 0 ? ( +

No attributes found.

+ ) : ( + visibleNameOptions.map((attribute) => { + const id = normalizeId(attribute.attribute_id); + return renderCheckboxOption( + draftFilters.attributeIds.includes(id), + () => toggleDraftListValue('attributeIds', id), + attribute.name, + ( +
+ +
+ ) + ); + }) + )} +
+ {filteredNameOptions.length > visibleNameOptions.length && ( + + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.presets && ( +
+
+ {renderCheckboxOption( + draftFilters.presets.includes('with'), + () => toggleDraftListValue('presets', 'with'), + 'With presets' + )} + {renderCheckboxOption( + draftFilters.presets.includes('without'), + () => toggleDraftListValue('presets', 'without'), + 'Without presets' + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.status && ( +
+
+ {renderCheckboxOption( + draftFilters.statuses.includes('active'), + () => toggleDraftListValue('statuses', 'active'), + 'Active' + )} + {renderCheckboxOption( + draftFilters.statuses.includes('inactive'), + () => toggleDraftListValue('statuses', 'inactive'), + 'Inactive' + )} +
+
+ )} +
+
+ +
+ + +
+
+ )} +
+
+ ); + }; + + const manageColumnsControl = ( +
+ + {showColumnsPanel && ( +
+ {COLUMN_OPTIONS.map((col) => ( +
+ + {col.label} + +
+ ))} +
+ )} +
+ ); + + const sortControl = ( +
+ + {showSortPanel && ( +
+ {SORT_OPTIONS.map((option) => { + const active = option.field === sortConfig.field && option.dir === sortConfig.dir; + return ( + + ); + })} +
+ )} +
+ ); + + const dateRangeLabel = `${format(new Date(dayStartMs(dateFrom || DEFAULT_DATE_FROM)), 'd MMM yy')} - ${format(new Date(dayStartMs(dateTo || DEFAULT_DATE_TO)), 'd MMM yy')}`; + + const dateControl = ( +
+ + {showDatePanel && ( +
+
+
+ + setDraftDateFrom(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+ + setDraftDateTo(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+
+ + +
+
+ )} +
+ ); + + const emptyMessage = + searchQuery || filtersActive || dateFilterEnabled ? 'No attributes match your search.' : 'No master attributes found.'; + + return ( +
+
+
+
+

Master Attributes

+ + {attributes.length} + +
+
+ +
+
+ + {showExportMenu && ( +
+ + +
+ )} +
+ + +
+
+ + {viewMode === 'grid' ? ( +
+
+
+
+ {renderFilterControl('left')} + {searchField} +
+
+ {viewToggle} + {addAttributeButton} +
+
+
+ + {loading ? ( +
+ + Loading master attributes... +
+ ) : filteredAttributes.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+
+ {pager.items.map((a) => ( +
+
+ setOpenActionId(open ? a.attribute_id : null)} + onEdit={() => handleOpenEdit(a)} + onDelete={() => openDeleteModal(a.attribute_id, a.name)} + /> +
+
+
+ +
+
+

{a.name}

+

{a.code}

+
+
+
{renderPresetBadges(presetList(a), 4)}
+
+ {renderStatusBadge(isAttributeActive(a.status))} +
+
+ ))} +
+
+ )} + +
+ ) : ( +
+
+
+ {searchField} + {addAttributeButton} +
+
+
+ {sortControl} + {dateControl} +
+
+ {renderFilterControl('right')} + {manageColumnsControl} + {viewToggle} +
+
+
+ + {loading ? ( +
+ + Loading master attributes... +
+ ) : ( +
+ + + + {visibleColumns.attributeName && ( + + )} + {visibleColumns.code && ( + + )} + {visibleColumns.presetValues && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + + + {filteredAttributes.length === 0 ? ( + + + + ) : ( + pager.items.map((a) => ( + + {visibleColumns.attributeName && ( + + )} + {visibleColumns.code && ( + + )} + {visibleColumns.presetValues && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + )) + )} + +
+ {renderSortLabel('Attribute Name', 'name')} + + {renderSortLabel('System Code', 'code')} + + {renderSortLabel('Preset Values', 'presets')} + + {renderSortLabel('Status', 'status')} + + {renderSortLabel('Action')} +
+ {emptyMessage} +
+
+
+ +
+
+

{a.name}

+

{a.code}

+
+
+
{a.code}{renderPresetBadges(presetList(a), 5)}{renderStatusBadge(isAttributeActive(a.status))} +
+ setOpenActionId(open ? a.attribute_id : null)} + onEdit={() => handleOpenEdit(a)} + onDelete={() => openDeleteModal(a.attribute_id, a.name)} + /> +
+
+
+ )} + +
+ )} + + {deleteTarget && ( +
+
e.stopPropagation()} + > +
+
+
+ +
+
+

+ Delete Attribute +

+

+ Are you sure you want to delete{' '} + "{deleteTarget.name}"? Products + using this attribute may lose these preset values. +

+
+
+ +
+
+ + +
+
+
+ )} + + } + > +
+
+
+ + setAttrName(e.target.value)} + className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ +
+ + setAttrCode(e.target.value)} + disabled={editingAttribute !== null} + className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors disabled:opacity-60 disabled:bg-muted/40" + /> +
+ +
+ +

+ These values appear as dropdown choices when creating or editing products. +

+
+ setNewPresetInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddPresetValue(); + } + }} + className="flex-1 h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground outline-none focus:border-primary" + /> + +
+ +
+ {presetValues.length === 0 ? ( + + No preset values added yet. Type a value above and click Add. + + ) : ( + presetValues.map((val, idx) => ( + + {val} + + + )) + )} +
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/app/(admin)/brands/page.tsx b/app/(admin)/brands/page.tsx new file mode 100644 index 0000000..6131014 --- /dev/null +++ b/app/(admin)/brands/page.tsx @@ -0,0 +1,1410 @@ +'use client'; + +import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { + Tag, + Plus, + Search, + RefreshCw, + ChevronDown, + ChevronRight, + Trash2, + Filter, + X, + Box, + FileSpreadsheet, + FileText, + Columns3, + GripVertical, + ArrowUpDown, + Calendar, + AlertTriangle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { format, subDays } from 'date-fns'; +import { AnimatePresence, motion } from '@/lib/motion'; +import { catalogService, BrandResponse } from '@/services/api/catalogService'; +import { getMediaUrl } from '@/services/api/config'; + +import ImageUpload from '@/components/ui/ImageUpload'; +import { SlideOver } from '@/components/ui/SlideOver'; +import { RowActionsMenu } from '@/components/ui/RowActionsMenu'; +import { ViewModeToggle } from '@/components/ui/ViewModeToggle'; +import { TablePagination, useClientPagination } from '@/components/ui/TablePagination'; + +const DEVICE_TYPES = ['laptop', 'tablet', 'mobile'] as const; +type DeviceType = (typeof DEVICE_TYPES)[number]; +type StatusFilter = 'active' | 'inactive'; +type SortField = 'name' | 'slug' | 'status'; +type SortDir = 'asc' | 'desc'; +type SortConfig = { field: SortField; dir: SortDir }; +type FilterSection = 'name' | 'deviceTypes' | 'status'; + +type BrandFilters = { + brandIds: string[]; + deviceTypes: DeviceType[]; + statuses: StatusFilter[]; +}; + +const EMPTY_FILTERS: BrandFilters = { + brandIds: [], + deviceTypes: [], + statuses: [], +}; + +const FILTER_PAGE_SIZE = 5; + +const EMPTY_COLUMNS = { + brandName: true, + slug: true, + deviceTypes: true, + status: true, + actions: true, +}; + +const COLUMN_OPTIONS = [ + { key: 'brandName' as const, label: 'Brand Name', locked: true }, + { key: 'slug' as const, label: 'Slug / Path', locked: false }, + { key: 'deviceTypes' as const, label: 'Device Types', locked: false }, + { key: 'status' as const, label: 'Status', locked: false }, + { key: 'actions' as const, label: 'Actions', locked: false }, +]; + +const SORT_OPTIONS: { field: SortField; dir: SortDir; label: string }[] = [ + { field: 'name', dir: 'asc', label: 'Name A-Z' }, + { field: 'name', dir: 'desc', label: 'Name Z-A' }, + { field: 'slug', dir: 'asc', label: 'Slug A-Z' }, + { field: 'slug', dir: 'desc', label: 'Slug Z-A' }, + { field: 'status', dir: 'asc', label: 'Status Active first' }, + { field: 'status', dir: 'desc', label: 'Status Inactive first' }, +]; + +const DEFAULT_SORT: SortConfig = { field: 'name', dir: 'asc' }; +const DEFAULT_DATE_FROM = format(subDays(new Date(), 30), 'yyyy-MM-dd'); +const DEFAULT_DATE_TO = format(new Date(), 'yyyy-MM-dd'); + +function normalizeId(value: unknown): string { + if (value == null) return ''; + return String(value).trim(); +} + +function coerceFlag(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (value == null) return false; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'enabled', 'active'].includes(normalized)) return true; + if (['false', '0', 'no', 'disabled', 'inactive', '', 'null', 'undefined'].includes(normalized)) return false; + return false; +} + +function dayStartMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); +} + +function dayEndMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 23, 59, 59, 999).getTime(); +} + +function formatDeviceTypes(types?: string[]): string { + if (!types || types.length === 0) return '—'; + return types.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(', '); +} + +export default function BrandsPage() { + const [brands, setBrands] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'table' | 'grid'>('table'); + const [openActionId, setOpenActionId] = useState(null); + const [showFilterPanel, setShowFilterPanel] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [showColumnsPanel, setShowColumnsPanel] = useState(false); + const [showSortPanel, setShowSortPanel] = useState(false); + const [showDatePanel, setShowDatePanel] = useState(false); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [activeFilters, setActiveFilters] = useState(EMPTY_FILTERS); + const [expandedFilterSections, setExpandedFilterSections] = useState>({ + name: false, + deviceTypes: true, + status: false, + }); + const [filterSectionSearch, setFilterSectionSearch] = useState({ name: '' }); + const [filterVisibleCounts, setFilterVisibleCounts] = useState({ name: FILTER_PAGE_SIZE }); + const [visibleColumns, setVisibleColumns] = useState(EMPTY_COLUMNS); + const [sortConfig, setSortConfig] = useState(DEFAULT_SORT); + const [dateFrom, setDateFrom] = useState(DEFAULT_DATE_FROM); + const [dateTo, setDateTo] = useState(DEFAULT_DATE_TO); + const [draftDateFrom, setDraftDateFrom] = useState(DEFAULT_DATE_FROM); + const [draftDateTo, setDraftDateTo] = useState(DEFAULT_DATE_TO); + const [dateFilterEnabled, setDateFilterEnabled] = useState(false); + const filterRef = useRef(null); + const exportRef = useRef(null); + const columnsRef = useRef(null); + const sortRef = useRef(null); + const dateRef = useRef(null); + + const [showBrandModal, setShowBrandModal] = useState(false); + const [editingBrand, setEditingBrand] = useState(null); + const [brandName, setBrandName] = useState(''); + const [brandLogo, setBrandLogo] = useState(''); + const [selectedDeviceTypes, setSelectedDeviceTypes] = useState([]); + const [hasMultipleDevices, setHasMultipleDevices] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchData = async () => { + setLoading(true); + try { + const fetchedBrands = await catalogService.getBrands(); + setBrands(fetchedBrands); + } catch (err: any) { + toast.error(err?.message || 'Failed to load brand data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + if (!showFilterPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!filterRef.current?.contains(event.target as Node)) setShowFilterPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowFilterPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showFilterPanel]); + + useEffect(() => { + if (!showExportMenu) return; + const onPointerDown = (event: MouseEvent) => { + if (!exportRef.current?.contains(event.target as Node)) setShowExportMenu(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowExportMenu(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showExportMenu]); + + useEffect(() => { + if (!showColumnsPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!columnsRef.current?.contains(event.target as Node)) setShowColumnsPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowColumnsPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showColumnsPanel]); + + useEffect(() => { + if (!showSortPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!sortRef.current?.contains(event.target as Node)) setShowSortPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowSortPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showSortPanel]); + + useEffect(() => { + if (!showDatePanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!dateRef.current?.contains(event.target as Node)) setShowDatePanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowDatePanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showDatePanel]); + + const closeOverlays = () => { + setShowFilterPanel(false); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + }; + + const handleViewModeChange = (mode: 'table' | 'grid') => { + setViewMode(mode); + setOpenActionId(null); + closeOverlays(); + }; + + const handleOpenCreate = () => { + setEditingBrand(null); + setBrandName(''); + setBrandLogo(''); + setSelectedDeviceTypes([]); + setHasMultipleDevices(false); + setShowBrandModal(true); + }; + + const handleOpenEdit = (brand: BrandResponse) => { + setEditingBrand(brand); + setBrandName(brand.name); + setBrandLogo(brand.logo_url || ''); + setSelectedDeviceTypes(brand.device_types || []); + setHasMultipleDevices((brand.device_types || []).length > 0); + setShowBrandModal(true); + }; + + const closeBrandDrawer = () => { + if (isSubmitting) return; + setShowBrandModal(false); + }; + + const handleSubmitBrand = async (e: React.FormEvent) => { + e.preventDefault(); + if (!brandName.trim()) { + toast.error('Brand name is required'); + return; + } + setIsSubmitting(true); + try { + const payloadDeviceTypes = hasMultipleDevices ? selectedDeviceTypes : []; + if (editingBrand) { + const updated = await catalogService.updateBrand(editingBrand.brand_id, { + name: brandName.trim(), + logo_url: brandLogo ? brandLogo.trim() : '', + device_types: payloadDeviceTypes, + }); + setBrands(brands.map((b) => (b.brand_id === updated.brand_id ? updated : b))); + toast.success(`Brand "${updated.name}" updated successfully`); + } else { + const created = await catalogService.createBrand({ + name: brandName.trim(), + logo_url: brandLogo ? brandLogo.trim() : '', + device_types: payloadDeviceTypes, + }); + setBrands([...brands, created]); + toast.success(`Brand "${created.name}" created successfully`); + } + setBrandName(''); + setBrandLogo(''); + setSelectedDeviceTypes([]); + setHasMultipleDevices(false); + setEditingBrand(null); + setShowBrandModal(false); + } catch (err: any) { + toast.error(err?.message || `Failed to ${editingBrand ? 'update' : 'create'} brand`); + } finally { + setIsSubmitting(false); + } + }; + + const openDeleteModal = (id: string, name: string) => { + setOpenActionId(null); + setDeleteTarget({ id, name }); + }; + + const closeDeleteModal = () => { + if (isDeleting) return; + setDeleteTarget(null); + }; + + const handleConfirmDelete = async () => { + if (!deleteTarget) return; + const { id, name } = deleteTarget; + setIsDeleting(true); + try { + setBrands((prev) => prev.filter((b) => b.brand_id !== id)); + await catalogService.deleteBrand(id); + toast.success(`Brand "${name}" deleted successfully`); + setDeleteTarget(null); + fetchData(); + } catch (err: any) { + toast.error(err?.message || 'Failed to delete brand'); + fetchData(); + } finally { + setIsDeleting(false); + } + }; + + const filteredBrands = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const fromTs = dateFilterEnabled && dateFrom ? dayStartMs(dateFrom) : null; + const toTs = dateFilterEnabled && dateTo ? dayEndMs(dateTo) : null; + + const result = brands.filter((b) => { + const searchMatch = + !q || + (b.name || '').toLowerCase().includes(q) || + (b.slug || '').toLowerCase().includes(q); + + const isActive = coerceFlag(b.is_active); + const statusMatch = + activeFilters.statuses.length === 0 || + (activeFilters.statuses.includes('active') && isActive) || + (activeFilters.statuses.includes('inactive') && !isActive); + + const brandMatch = + activeFilters.brandIds.length === 0 || + activeFilters.brandIds.includes(normalizeId(b.brand_id)); + + const brandDeviceTypes = b.device_types || []; + const deviceMatch = + activeFilters.deviceTypes.length === 0 || + activeFilters.deviceTypes.some((dt) => brandDeviceTypes.includes(dt)); + + let dateMatch = true; + if (fromTs != null && toTs != null && !Number.isNaN(fromTs) && !Number.isNaN(toTs)) { + const createdTs = new Date(b.created_at).getTime(); + if (!Number.isNaN(createdTs)) { + dateMatch = createdTs >= fromTs && createdTs <= toTs; + } + } + + return searchMatch && brandMatch && statusMatch && deviceMatch && dateMatch; + }); + + const dir = sortConfig.dir === 'asc' ? 1 : -1; + return [...result].sort((a, b) => { + if (sortConfig.field === 'status') { + return (Number(coerceFlag(a.is_active)) - Number(coerceFlag(b.is_active))) * dir; + } + const left = sortConfig.field === 'name' ? a.name || '' : a.slug || ''; + const right = sortConfig.field === 'name' ? b.name || '' : b.slug || ''; + return left.localeCompare(right, undefined, { sensitivity: 'base' }) * dir; + }); + }, [brands, searchQuery, activeFilters, dateFrom, dateTo, dateFilterEnabled, sortConfig]); + + const pager = useClientPagination(filteredBrands); + + const filtersActive = + activeFilters.brandIds.length > 0 || + activeFilters.deviceTypes.length > 0 || + activeFilters.statuses.length > 0; + + const sortedBrandFilterOptions = useMemo( + () => [...brands].sort((a, b) => a.name.localeCompare(b.name)), + [brands] + ); + + const toggleFilterSection = (section: FilterSection) => { + setExpandedFilterSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + + const toggleDraftListValue = ( + key: keyof Pick, + value: T + ) => { + setDraftFilters((prev) => { + const current = prev[key] as T[]; + return { + ...prev, + [key]: current.includes(value) ? current.filter((item) => item !== value) : [...current, value], + }; + }); + }; + + const openFilterPanel = () => { + setDraftFilters(activeFilters); + setFilterSectionSearch({ name: '' }); + setFilterVisibleCounts({ name: FILTER_PAGE_SIZE }); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + setShowFilterPanel(true); + }; + + const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length; + + const toggleColumn = (key: keyof typeof EMPTY_COLUMNS) => { + if (key === 'brandName') return; + setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleExportExcel = () => { + if (filteredBrands.length === 0) { + toast.warning('No brands to export'); + return; + } + const headers = ['S.No', 'Brand Name', 'Slug / Path', 'Device Types', 'Status']; + const rows = filteredBrands.map((b, i) => [ + i + 1, + b.name, + b.slug || '', + formatDeviceTypes(b.device_types), + coerceFlag(b.is_active) ? 'Active' : 'Inactive', + ]); + const csvContent = [headers.join(','), ...rows.map((row) => row.map((val) => `"${String(val).replace(/"/g, '""')}"`).join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `ifixkart_brands_${format(new Date(), 'yyyyMMdd')}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + setShowExportMenu(false); + toast.success(`Downloaded ${filteredBrands.length} ${filteredBrands.length === 1 ? 'brand' : 'brands'} as CSV`); + }; + + const handleExportPDF = () => { + if (filteredBrands.length === 0) { + toast.warning('No brands to export'); + return; + } + const printWindow = window.open('', '_blank'); + if (!printWindow) { + toast.error('Allow pop-ups to open the brands print preview'); + return; + } + const html = ` + + + iFixKart Brands PDF Export + + + +

iFixKart Brands (${filteredBrands.length} Records)

+

Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}

+ + + + + + + + + + + + ${filteredBrands.map((b, i) => ` + + + + + + + + `).join('')} + +
S.NoBrand NameSlug / PathDevice TypesStatus
${i + 1}${b.name}${b.slug || ''}${formatDeviceTypes(b.device_types)}${coerceFlag(b.is_active) ? 'Active' : 'Inactive'}
+ + + + `; + printWindow.document.write(html); + printWindow.document.close(); + setShowExportMenu(false); + toast.success(`Print preview opened for ${filteredBrands.length} ${filteredBrands.length === 1 ? 'brand' : 'brands'}`); + }; + + const dateRangeLabel = `${format(new Date(dayStartMs(dateFrom || DEFAULT_DATE_FROM)), 'd MMM yy')} - ${format(new Date(dayStartMs(dateTo || DEFAULT_DATE_TO)), 'd MMM yy')}`; + + const dataCardShell = 'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0'; + + const renderSortLabel = (label: string, field?: SortField, showIcon = true) => { + const active = field != null && sortConfig.field === field; + return ( + + {label} + {showIcon && ( + + )} + + ); + }; + + const renderStatusBadge = (active: boolean) => ( + + {active ? 'Active' : 'Inactive'} + + ); + + const renderDeviceTypeBadges = (types?: string[]) => { + if (!types || types.length === 0) { + return ; + } + return ( +
+ {types.map((dt) => ( + + {dt} + + ))} +
+ ); + }; + + const renderBrandAvatar = (brand: BrandResponse, size: 'sm' | 'md' = 'md') => { + const dim = size === 'sm' ? 'w-9 h-9' : 'w-10 h-10'; + return ( +
+ {brand.logo_url ? ( + {brand.name} + ) : ( + + )} +
+ ); + }; + + const searchField = ( +
+ + setSearchQuery(e.target.value)} + className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ ); + + const addBrandButton = ( + + ); + + const viewToggle = ; + + const renderFilterControl = (align: 'left' | 'right') => { + const nameQuery = filterSectionSearch.name.trim().toLowerCase(); + const filteredNameOptions = sortedBrandFilterOptions.filter((brand) => + !nameQuery || brand.name.toLowerCase().includes(nameQuery) || (brand.slug || '').toLowerCase().includes(nameQuery) + ); + const visibleNameOptions = filteredNameOptions.slice(0, filterVisibleCounts.name); + + const renderSectionSearch = (value: string, onChange: (next: string) => void) => ( +
+ + { + onChange(e.target.value); + setFilterVisibleCounts({ name: FILTER_PAGE_SIZE }); + }} + className="w-full h-8 pl-8 pr-3 crm-radius-control border border-border bg-card text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + /> +
+ ); + + const renderCheckboxOption = (checked: boolean, onToggle: () => void, label: string, icon?: ReactNode) => ( + + ); + + return ( +
+ + + {showFilterPanel && ( + +
+ + + Filter + + +
+ +
+
+ + {expandedFilterSections.name && ( +
+
+ {renderSectionSearch(filterSectionSearch.name, (value) => + setFilterSectionSearch({ name: value }) + )} +
+ {visibleNameOptions.length === 0 ? ( +

No brands found.

+ ) : ( + visibleNameOptions.map((brand) => { + const id = normalizeId(brand.brand_id); + return renderCheckboxOption( + draftFilters.brandIds.includes(id), + () => toggleDraftListValue('brandIds', id), + brand.name, + renderBrandAvatar(brand, 'sm') + ); + }) + )} +
+ {filteredNameOptions.length > visibleNameOptions.length && ( + + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.deviceTypes && ( +
+
+ {DEVICE_TYPES.map((dt) => + renderCheckboxOption( + draftFilters.deviceTypes.includes(dt), + () => toggleDraftListValue('deviceTypes', dt), + dt.charAt(0).toUpperCase() + dt.slice(1) + ) + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.status && ( +
+
+ {renderCheckboxOption( + draftFilters.statuses.includes('active'), + () => toggleDraftListValue('statuses', 'active'), + 'Active' + )} + {renderCheckboxOption( + draftFilters.statuses.includes('inactive'), + () => toggleDraftListValue('statuses', 'inactive'), + 'Inactive' + )} +
+
+ )} +
+
+ +
+ + +
+
+ )} +
+
+ ); + }; + + const manageColumnsControl = ( +
+ + {showColumnsPanel && ( +
+ {COLUMN_OPTIONS.map((col) => ( +
+ + {col.label} + +
+ ))} +
+ )} +
+ ); + + const sortControl = ( +
+ + {showSortPanel && ( +
+ {SORT_OPTIONS.map((option) => { + const active = option.field === sortConfig.field && option.dir === sortConfig.dir; + return ( + + ); + })} +
+ )} +
+ ); + + const dateControl = ( +
+ + {showDatePanel && ( +
+
+
+ + setDraftDateFrom(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+ + setDraftDateTo(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+
+ + +
+
+ )} +
+ ); + + const emptyMessage = + searchQuery || filtersActive || dateFilterEnabled + ? 'No brands match your search.' + : 'No brands found.'; + + return ( +
+
+
+
+

Brands

+ + {brands.length} + +
+
+ +
+
+ + {showExportMenu && ( +
+ + +
+ )} +
+ + +
+
+ + {viewMode === 'grid' ? ( +
+
+
+
+ {renderFilterControl('left')} + {searchField} +
+
+ {viewToggle} + {addBrandButton} +
+
+
+ + {loading ? ( +
+ + Loading brands... +
+ ) : filteredBrands.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+
+ {pager.items.map((b) => ( +
+
+ setOpenActionId(open ? b.brand_id : null)} + onEdit={() => handleOpenEdit(b)} + onDelete={() => openDeleteModal(b.brand_id, b.name)} + /> +
+
+ {renderBrandAvatar(b)} +
+

{b.name}

+

{b.slug}

+
+
+
+ {renderDeviceTypeBadges(b.device_types)} +
+
+ {renderStatusBadge(coerceFlag(b.is_active))} +
+
+ ))} +
+
+ )} + +
+ ) : ( +
+
+
+ {searchField} + {addBrandButton} +
+
+
+ {sortControl} + {dateControl} +
+
+ {renderFilterControl('right')} + {manageColumnsControl} + {viewToggle} +
+
+
+ + {loading ? ( +
+ + Loading brands... +
+ ) : ( +
+ + + + {visibleColumns.brandName && ( + + )} + {visibleColumns.slug && ( + + )} + {visibleColumns.deviceTypes && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + + + {filteredBrands.length === 0 ? ( + + + + ) : ( + pager.items.map((b) => ( + + {visibleColumns.brandName && ( + + )} + {visibleColumns.slug && ( + + )} + {visibleColumns.deviceTypes && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + )) + )} + +
+ {renderSortLabel('Brand Name', 'name')} + + {renderSortLabel('Slug / Path', 'slug')} + + {renderSortLabel('Device Types')} + + {renderSortLabel('Status', 'status')} + + {renderSortLabel('Action')} +
+ {emptyMessage} +
+
+ {renderBrandAvatar(b, 'sm')} +
+

{b.name}

+

{b.slug}

+
+
+
{b.slug}{renderDeviceTypeBadges(b.device_types)}{renderStatusBadge(coerceFlag(b.is_active))} +
+ setOpenActionId(open ? b.brand_id : null)} + onEdit={() => handleOpenEdit(b)} + onDelete={() => openDeleteModal(b.brand_id, b.name)} + /> +
+
+
+ )} + +
+ )} + + {deleteTarget && ( +
+
e.stopPropagation()} + > +
+
+
+ +
+
+

+ Delete Brand +

+

+ Are you sure you want to delete{' '} + "{deleteTarget.name}" and all its + related products, models, and series? +

+
+
+ +
+
+ + +
+
+
+ )} + + } + > +
+
+
+ + setBrandName(e.target.value)} + className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ +
+ { + setHasMultipleDevices(e.target.checked); + if (!e.target.checked) setSelectedDeviceTypes([]); + }} + className="mt-0.5 rounded border-border text-primary focus:ring-primary cursor-pointer" + /> + +
+ + {hasMultipleDevices && ( +
+ +
+ {DEVICE_TYPES.map((dt) => ( + + ))} +
+
+ )} + +
+ + setBrandLogo(url)} + onClear={() => setBrandLogo('')} + /> +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/app/(admin)/categories/page.tsx b/app/(admin)/categories/page.tsx new file mode 100644 index 0000000..a12e22d --- /dev/null +++ b/app/(admin)/categories/page.tsx @@ -0,0 +1,1788 @@ +'use client'; + +import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'; +import { Plus, Search, RefreshCw, Folder, ChevronDown, ChevronUp, ChevronRight, Link as LinkIcon, Tag, Trash2, Filter, X, Box, FileSpreadsheet, FileText, Columns3, GripVertical, ArrowUpDown, Calendar, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import { format, subDays } from 'date-fns'; +import { AnimatePresence, motion } from '@/lib/motion'; +import { catalogService, CategoryResponse } from '@/services/api/catalogService'; +import { getAccessToken } from '@/services/api/client'; +import ImageUpload from '@/components/ui/ImageUpload'; +import { SlideOver } from '@/components/ui/SlideOver'; +import { RowActionsMenu } from '@/components/ui/RowActionsMenu'; +import { ViewModeToggle } from '@/components/ui/ViewModeToggle'; +import { TablePagination, useClientPagination } from '@/components/ui/TablePagination'; +import { CustomSelect } from '@/components/ui/CustomSelect'; + +function normalizeId(value: unknown): string { + if (value == null) return ''; + return String(value).trim(); +} + +function isRootParentId(value: unknown): boolean { + const id = normalizeId(value).toLowerCase(); + return id === '' || id === 'null' || id === 'undefined' || id === '0' || id === 'root' || id === '- root -' || id === 'none'; +} + +function coerceFlag(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (value == null) return false; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'enabled', 'active'].includes(normalized)) return true; + if (['false', '0', 'no', 'disabled', 'inactive', '', 'null', 'undefined'].includes(normalized)) return false; + return false; +} + +function idsEqual(a: unknown, b: unknown): boolean { + const left = normalizeId(a); + const right = normalizeId(b); + return left !== '' && right !== '' && left === right; +} + +function dayStartMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); +} + +function dayEndMs(isoDate: string): number { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) return NaN; + return new Date(year, month - 1, day, 23, 59, 59, 999).getTime(); +} + +type StatusFilter = 'active' | 'inactive'; +type FeatureFilter = 'enabled' | 'disabled'; +type SortField = 'name' | 'slug' | 'parent' | 'sortOrder' | 'status'; +type SortDir = 'asc' | 'desc'; +type SortConfig = { field: SortField; dir: SortDir }; +type FilterSection = 'name' | 'parent' | 'feature' | 'status'; + +type CategoryFilters = { + categoryIds: string[]; + parentIds: string[]; + features: FeatureFilter[]; + statuses: StatusFilter[]; +}; + +const EMPTY_FILTERS: CategoryFilters = { + categoryIds: [], + parentIds: [], + features: [], + statuses: [], +}; + +const FILTER_PAGE_SIZE = 5; + +const EMPTY_COLUMNS = { + categoryName: true, + slug: true, + parentCategory: true, + parentFeature: true, + sortOrder: true, + status: true, + actions: true, +}; + +const COLUMN_OPTIONS = [ + { key: 'categoryName' as const, label: 'Category Name', locked: true }, + { key: 'slug' as const, label: 'Slug / Path', locked: false }, + { key: 'parentCategory' as const, label: 'Parent Category', locked: false }, + { key: 'parentFeature' as const, label: 'Parent Feature', locked: false }, + { key: 'sortOrder' as const, label: 'Sort Order', locked: false }, + { key: 'status' as const, label: 'Status', locked: false }, + { key: 'actions' as const, label: 'Actions', locked: false }, +]; + +const SORT_OPTIONS: { field: SortField; dir: SortDir; label: string }[] = [ + { field: 'name', dir: 'asc', label: 'Name A-Z' }, + { field: 'name', dir: 'desc', label: 'Name Z-A' }, + { field: 'slug', dir: 'asc', label: 'Slug A-Z' }, + { field: 'slug', dir: 'desc', label: 'Slug Z-A' }, + { field: 'parent', dir: 'asc', label: 'Parent A-Z' }, + { field: 'parent', dir: 'desc', label: 'Parent Z-A' }, + { field: 'sortOrder', dir: 'asc', label: 'Sort Order Ascending' }, + { field: 'sortOrder', dir: 'desc', label: 'Sort Order Descending' }, + { field: 'status', dir: 'asc', label: 'Status Active first' }, + { field: 'status', dir: 'desc', label: 'Status Inactive first' }, +]; + +const DEFAULT_SORT: SortConfig = { field: 'sortOrder', dir: 'asc' }; +const DEFAULT_DATE_FROM = format(subDays(new Date(), 30), 'yyyy-MM-dd'); +const DEFAULT_DATE_TO = format(new Date(), 'yyyy-MM-dd'); + +export default function CategoriesPage() { + + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'table' | 'grid'>('table'); + const [openActionId, setOpenActionId] = useState(null); + const [showFilterPanel, setShowFilterPanel] = useState(false); + const [showExportMenu, setShowExportMenu] = useState(false); + const [showColumnsPanel, setShowColumnsPanel] = useState(false); + const [showSortPanel, setShowSortPanel] = useState(false); + const [showDatePanel, setShowDatePanel] = useState(false); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [activeFilters, setActiveFilters] = useState(EMPTY_FILTERS); + const [expandedFilterSections, setExpandedFilterSections] = useState>({ + name: false, + parent: false, + feature: true, + status: false, + }); + const [filterSectionSearch, setFilterSectionSearch] = useState({ name: '', parent: '' }); + const [filterVisibleCounts, setFilterVisibleCounts] = useState({ name: FILTER_PAGE_SIZE, parent: FILTER_PAGE_SIZE }); + const [visibleColumns, setVisibleColumns] = useState(EMPTY_COLUMNS); + const [sortConfig, setSortConfig] = useState(DEFAULT_SORT); + const [dateFrom, setDateFrom] = useState(DEFAULT_DATE_FROM); + const [dateTo, setDateTo] = useState(DEFAULT_DATE_TO); + const [draftDateFrom, setDraftDateFrom] = useState(DEFAULT_DATE_FROM); + const [draftDateTo, setDraftDateTo] = useState(DEFAULT_DATE_TO); + const [dateFilterEnabled, setDateFilterEnabled] = useState(false); + const filterRef = useRef(null); + const exportRef = useRef(null); + const columnsRef = useRef(null); + const sortRef = useRef(null); + const dateRef = useRef(null); + + // Category Modal States + const [showCategoryModal, setShowCategoryModal] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [categoryName, setCategoryName] = useState(''); + const [parentCatId, setParentCatId] = useState(''); + const [catDesc, setCatDesc] = useState(''); + const [catImg, setCatImg] = useState(''); + const [catSortOrder, setCatSortOrder] = useState('0'); + const [catParentFeature, setCatParentFeature] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Flyout mega menu config + const [flyoutOpen, setFlyoutOpen] = useState(false); + const [flyoutLinks, setFlyoutLinks] = useState<{ label: string; url: string }[]>([ + { label: 'View All', url: '' }, + { label: 'Best Prices', url: '' }, + { label: 'New Releases', url: '' }, + { label: 'Featured Items', url: '' }, + ]); + const [flyoutBrands, setFlyoutBrands] = useState<{ label: string; url: string }[]>([ + { label: 'Apple Official', url: '/shop?brand=apple' }, + { label: 'Samsung Galaxy', url: '/shop?brand=samsung' }, + { label: 'Sony Electronics', url: '/shop?brand=sony' }, + { label: 'Dell Systems', url: '/shop?brand=dell' }, + ]); + const [flyoutPromoText, setFlyoutPromoText] = useState('Get 20% OFF'); + const [flyoutSaving, setFlyoutSaving] = useState(false); + + const fetchData = async () => { + setLoading(true); + try { + const fetchedCats = await catalogService.getCategories(); + setCategories(fetchedCats); + } catch (err: any) { + toast.error(err?.message || 'Failed to load category master data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + if (!showFilterPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!filterRef.current?.contains(event.target as Node)) { + setShowFilterPanel(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowFilterPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showFilterPanel]); + + useEffect(() => { + if (!showExportMenu) return; + const onPointerDown = (event: MouseEvent) => { + if (!exportRef.current?.contains(event.target as Node)) { + setShowExportMenu(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowExportMenu(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showExportMenu]); + + useEffect(() => { + if (!showColumnsPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!columnsRef.current?.contains(event.target as Node)) { + setShowColumnsPanel(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowColumnsPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showColumnsPanel]); + + useEffect(() => { + if (!showSortPanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!sortRef.current?.contains(event.target as Node)) { + setShowSortPanel(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowSortPanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showSortPanel]); + + useEffect(() => { + if (!showDatePanel) return; + const onPointerDown = (event: MouseEvent) => { + if (!dateRef.current?.contains(event.target as Node)) { + setShowDatePanel(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowDatePanel(false); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [showDatePanel]); + + const closeOverlays = () => { + setShowFilterPanel(false); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + }; + + const handleViewModeChange = (mode: 'table' | 'grid') => { + setViewMode(mode); + setOpenActionId(null); + closeOverlays(); + }; + + const handleOpenCreate = () => { + setEditingCategory(null); + setCategoryName(''); + setParentCatId(''); + setCatDesc(''); + setCatImg(''); + setCatSortOrder('0'); + setCatParentFeature(false); + setShowCategoryModal(true); + }; + + const handleOpenEdit = (c: CategoryResponse) => { + setEditingCategory(c); + setCategoryName(c.name); + setParentCatId(c.parent_category_id || ''); + setCatDesc(c.description || ''); + setCatImg(c.image_url || ''); + setCatSortOrder(c.sort_order || '0'); + setCatParentFeature(coerceFlag(c.is_parent_feature)); + setFlyoutOpen(false); + // Fetch existing flyout config for this category from storefront API + const slug = c.slug; + fetch(`/api/v1/storefront/layout/home?region=category_flyout_${slug}`, { cache: 'no-store' }) + .then((r) => r.ok ? r.json() : []) + .then((data: any[]) => { + if (data && data.length > 0) { + const meta = data[0]?.metadata_json || {}; + if (meta.links) setFlyoutLinks(meta.links); + if (meta.brands) setFlyoutBrands(meta.brands); + if (meta.promoText) setFlyoutPromoText(meta.promoText); + } else { + // Reset to defaults with category slug filled in + setFlyoutLinks([ + { label: 'View All ' + c.name, url: `/shop?category=${slug}` }, + { label: 'Best Prices', url: `/shop?category=${slug}&sort=price_asc` }, + { label: 'New Releases', url: `/shop?category=${slug}&sort=newest` }, + { label: 'Featured Items', url: `/shop?category=${slug}&featured=true` }, + ]); + setFlyoutBrands([ + { label: 'Apple Official', url: '/shop?brand=apple' }, + { label: 'Samsung Galaxy', url: '/shop?brand=samsung' }, + { label: 'Sony Electronics', url: '/shop?brand=sony' }, + { label: 'Dell Systems', url: '/shop?brand=dell' }, + ]); + setFlyoutPromoText('Get 20% OFF ' + c.name); + } + }) + .catch(() => {}); + setShowCategoryModal(true); + }; + + const closeCategoryDrawer = () => { + if (isSubmitting) return; + setShowCategoryModal(false); + }; + + const handleSubmitCategory = async (e: React.FormEvent) => { + e.preventDefault(); + if (!categoryName.trim()) { + toast.error('Category name is required'); + return; + } + setIsSubmitting(true); + try { + if (editingCategory) { + const updated = await catalogService.updateCategory(editingCategory.category_id, { + name: categoryName.trim(), + parent_category_id: parentCatId || null, + description: catDesc.trim() || '', + image_url: catImg ? catImg.trim() : '', + sort_order: catSortOrder, + is_parent_feature: catParentFeature, + }); + setCategories(categories.map((c) => (c.category_id === updated.category_id ? updated : c))); + toast.success(`Category "${updated.name}" updated successfully`); + } else { + const created = await catalogService.createCategory({ + name: categoryName.trim(), + parent_category_id: parentCatId || null, + description: catDesc.trim() || '', + image_url: catImg ? catImg.trim() : '', + sort_order: catSortOrder, + is_parent_feature: catParentFeature, + }); + setCategories([...categories, created]); + toast.success(`Category "${created.name}" created successfully`); + } + setCategoryName(''); + setParentCatId(''); + setCatDesc(''); + setCatImg(''); + setCatSortOrder('0'); + setCatParentFeature(false); + setEditingCategory(null); + setShowCategoryModal(false); + } catch (err: any) { + toast.error(err?.message || `Failed to ${editingCategory ? 'update' : 'create'} category`); + } finally { + setIsSubmitting(false); + } + }; + + const openDeleteModal = (id: string, name: string) => { + setOpenActionId(null); + setDeleteTarget({ id, name }); + }; + + const closeDeleteModal = () => { + if (isDeleting) return; + setDeleteTarget(null); + }; + + const handleConfirmDelete = async () => { + if (!deleteTarget) return; + const { id, name } = deleteTarget; + setIsDeleting(true); + try { + setCategories((prev) => prev.filter((c) => c.category_id !== id)); + await catalogService.deleteCategory(id); + toast.success(`Category "${name}" deleted successfully`); + setDeleteTarget(null); + fetchData(); + } catch (err: any) { + toast.error(err?.message || 'Failed to delete category'); + fetchData(); + } finally { + setIsDeleting(false); + } + }; + + const handleSaveFlyout = async () => { + if (!editingCategory) return; + setFlyoutSaving(true); + try { + const token = getAccessToken(); + const backendUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + await fetch(`${backendUrl}/api/v1/admin/storefront/content/bulk`, { + method: 'POST', + headers, + body: JSON.stringify({ + items: [{ + content_id: null, + page: 'home', + region: `category_flyout_${editingCategory.slug}`, + type: 'category_flyout', + title: `${editingCategory.name} Flyout Menu`, + subtitle: '', + display_order: 0, + metadata_json: { + links: flyoutLinks, + brands: flyoutBrands, + promoText: flyoutPromoText, + } + }] + }) + }); + toast.success('Category flyout menu published to the storefront'); + } catch (err: any) { + toast.error('Could not save the category flyout menu'); + } finally { + setFlyoutSaving(false); + } + }; + + const getParentLabel = (category: CategoryResponse) => { + if (isRootParentId(category.parent_category_id)) return '- Root -'; + const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, category.parent_category_id)); + return parent?.name || '- Root -'; + }; + + const filteredCategories = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const fromTs = dateFilterEnabled && dateFrom ? dayStartMs(dateFrom) : null; + const toTs = dateFilterEnabled && dateTo ? dayEndMs(dateTo) : null; + + const result = categories.filter((c) => { + const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id)); + const searchMatch = + !q || + (c.name || '').toLowerCase().includes(q) || + (c.slug || '').toLowerCase().includes(q) || + (c.description || '').toLowerCase().includes(q) || + (parent?.name || '').toLowerCase().includes(q); + + const isActive = coerceFlag(c.is_active); + const statusMatch = + activeFilters.statuses.length === 0 || + (activeFilters.statuses.includes('active') && isActive) || + (activeFilters.statuses.includes('inactive') && !isActive); + + const isRoot = isRootParentId(c.parent_category_id); + const parentMatch = + activeFilters.parentIds.length === 0 || + (isRoot && activeFilters.parentIds.includes('root')) || + activeFilters.parentIds.some((id) => id !== 'root' && idsEqual(c.parent_category_id, id)); + + const parentFeatureEnabled = coerceFlag(c.is_parent_feature); + const featureMatch = + activeFilters.features.length === 0 || + (activeFilters.features.includes('enabled') && parentFeatureEnabled) || + (activeFilters.features.includes('disabled') && !parentFeatureEnabled); + + const categoryMatch = + activeFilters.categoryIds.length === 0 || + activeFilters.categoryIds.includes(normalizeId(c.category_id)); + + let dateMatch = true; + if (fromTs != null && toTs != null && !Number.isNaN(fromTs) && !Number.isNaN(toTs)) { + const createdTs = new Date(c.created_at).getTime(); + if (!Number.isNaN(createdTs)) { + dateMatch = createdTs >= fromTs && createdTs <= toTs; + } + } + + return searchMatch && categoryMatch && statusMatch && parentMatch && featureMatch && dateMatch; + }); + + const dir = sortConfig.dir === 'asc' ? 1 : -1; + return [...result].sort((a, b) => { + if (sortConfig.field === 'sortOrder') { + return (parseInt(a.sort_order || '0', 10) - parseInt(b.sort_order || '0', 10)) * dir; + } + if (sortConfig.field === 'status') { + return (Number(coerceFlag(a.is_active)) - Number(coerceFlag(b.is_active))) * dir; + } + const parentA = getParentLabel(a); + const parentB = getParentLabel(b); + const left = + sortConfig.field === 'name' ? a.name || '' : + sortConfig.field === 'slug' ? a.slug || '' : + parentA; + const right = + sortConfig.field === 'name' ? b.name || '' : + sortConfig.field === 'slug' ? b.slug || '' : + parentB; + return left.localeCompare(right, undefined, { sensitivity: 'base' }) * dir; + }); + }, [categories, searchQuery, activeFilters, dateFrom, dateTo, dateFilterEnabled, sortConfig]); + + const pager = useClientPagination(filteredCategories); + + const parentCategoryOptions = useMemo(() => { + const usedParentIds = new Set( + categories + .map((c) => normalizeId(c.parent_category_id)) + .filter((id) => id && !isRootParentId(id)) + ); + + const availableParents = categories.filter((c) => usedParentIds.has(normalizeId(c.category_id))); + const source = availableParents.length > 0 ? availableParents : categories; + + return [...source].sort((a, b) => a.name.localeCompare(b.name)); + }, [categories]); + + const filtersActive = + activeFilters.categoryIds.length > 0 || + activeFilters.parentIds.length > 0 || + activeFilters.features.length > 0 || + activeFilters.statuses.length > 0; + + const sortedCategoryFilterOptions = useMemo( + () => [...categories].sort((a, b) => a.name.localeCompare(b.name)), + [categories] + ); + + const parentFilterOptions = useMemo(() => { + const rootOption = { id: 'root', name: 'Root Category' }; + const parents = parentCategoryOptions.map((cat) => ({ + id: normalizeId(cat.category_id), + name: cat.name, + })); + return [rootOption, ...parents]; + }, [parentCategoryOptions]); + + const toggleFilterSection = (section: FilterSection) => { + setExpandedFilterSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + + const toggleDraftListValue = ( + key: keyof Pick, + value: T + ) => { + setDraftFilters((prev) => { + const current = prev[key] as T[]; + return { + ...prev, + [key]: current.includes(value) ? current.filter((item) => item !== value) : [...current, value], + }; + }); + }; + + const openFilterPanel = () => { + setDraftFilters(activeFilters); + setFilterSectionSearch({ name: '', parent: '' }); + setFilterVisibleCounts({ name: FILTER_PAGE_SIZE, parent: FILTER_PAGE_SIZE }); + setShowColumnsPanel(false); + setShowSortPanel(false); + setShowDatePanel(false); + setShowFilterPanel(true); + }; + + const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length; + + const toggleColumn = (key: keyof typeof EMPTY_COLUMNS) => { + if (key === 'categoryName') return; + setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleExportExcel = () => { + if (filteredCategories.length === 0) { + toast.warning('No categories to export'); + return; + } + + const headers = ['S.No', 'Category Name', 'Slug / Path', 'Parent Category', 'Parent Feature', 'Sort Order', 'Status']; + const rows = filteredCategories.map((c, i) => [ + i + 1, + c.name, + c.slug || '', + getParentLabel(c), + coerceFlag(c.is_parent_feature) ? 'Enabled' : 'Disabled', + c.sort_order || '0', + coerceFlag(c.is_active) ? 'Active' : 'Inactive', + ]); + + const csvContent = [headers.join(','), ...rows.map((row) => row.map((val) => `"${String(val).replace(/"/g, '""')}"`).join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `ifixkart_categories_${format(new Date(), 'yyyyMMdd')}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + setShowExportMenu(false); + toast.success(`Downloaded ${filteredCategories.length} ${filteredCategories.length === 1 ? 'category' : 'categories'} as CSV`); + }; + + const handleExportPDF = () => { + if (filteredCategories.length === 0) { + toast.warning('No categories to export'); + return; + } + + const printWindow = window.open('', '_blank'); + if (!printWindow) { + toast.error('Allow pop-ups to open the categories print preview'); + return; + } + + const html = ` + + + iFixKart Categories PDF Export + + + +

iFixKart Categories (${filteredCategories.length} Records)

+

Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}

+ + + + + + + + + + + + + + ${filteredCategories.map((c, i) => ` + + + + + + + + + + `).join('')} + +
S.NoCategory NameSlug / PathParent CategoryParent FeatureSort OrderStatus
${i + 1}${c.name}${c.slug || ''}${getParentLabel(c)}${coerceFlag(c.is_parent_feature) ? 'Enabled' : 'Disabled'}${c.sort_order || '0'}${coerceFlag(c.is_active) ? 'Active' : 'Inactive'}
+ + + + `; + printWindow.document.write(html); + printWindow.document.close(); + setShowExportMenu(false); + toast.success(`Print preview opened for ${filteredCategories.length} ${filteredCategories.length === 1 ? 'category' : 'categories'}`); + }; + + const dateRangeLabel = `${format(new Date(dayStartMs(dateFrom || DEFAULT_DATE_FROM)), 'd MMM yy')} - ${format(new Date(dayStartMs(dateTo || DEFAULT_DATE_TO)), 'd MMM yy')}`; + + const dataCardShell = 'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0'; + + const renderSortLabel = (label: string, field?: SortField, showIcon = true) => { + const active = field != null && sortConfig.field === field; + return ( + + {label} + {showIcon && ( + + )} + + ); + }; + + const renderStatusBadge = (active: boolean) => ( + + {active ? 'Active' : 'Inactive'} + + ); + + const renderFeatureBadge = (enabled: boolean) => ( + + {enabled ? 'Enabled' : 'Disabled'} + + ); + + const searchField = ( +
+ + setSearchQuery(e.target.value)} + className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ ); + + const addCategoryButton = ( + + ); + + const viewToggle = ( + + ); + + const renderFilterControl = (align: 'left' | 'right') => { + const nameQuery = filterSectionSearch.name.trim().toLowerCase(); + const parentQuery = filterSectionSearch.parent.trim().toLowerCase(); + const filteredNameOptions = sortedCategoryFilterOptions.filter((cat) => + !nameQuery || cat.name.toLowerCase().includes(nameQuery) || (cat.slug || '').toLowerCase().includes(nameQuery) + ); + const filteredParentOptions = parentFilterOptions.filter((option) => + !parentQuery || option.name.toLowerCase().includes(parentQuery) + ); + const visibleNameOptions = filteredNameOptions.slice(0, filterVisibleCounts.name); + const visibleParentOptions = filteredParentOptions.slice(0, filterVisibleCounts.parent); + + const renderSectionSearch = ( + section: 'name' | 'parent', + value: string, + onChange: (next: string) => void + ) => ( +
+ + { + onChange(e.target.value); + setFilterVisibleCounts((prev) => ({ ...prev, [section]: FILTER_PAGE_SIZE })); + }} + className="w-full h-8 pl-8 pr-3 crm-radius-control border border-border bg-card text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + /> +
+ ); + + const renderCheckboxOption = ( + checked: boolean, + onToggle: () => void, + label: string, + icon?: ReactNode + ) => ( + + ); + + return ( +
+ + + {showFilterPanel && ( + +
+ + + Filter + + +
+ +
+
+ + {expandedFilterSections.name && ( +
+
+ {renderSectionSearch('name', filterSectionSearch.name, (value) => + setFilterSectionSearch((prev) => ({ ...prev, name: value })) + )} +
+ {visibleNameOptions.length === 0 ? ( +

No categories found.

+ ) : ( + visibleNameOptions.map((cat) => { + const id = normalizeId(cat.category_id); + return renderCheckboxOption( + draftFilters.categoryIds.includes(id), + () => toggleDraftListValue('categoryIds', id), + cat.name, + ( +
+ +
+ ) + ); + }) + )} +
+ {filteredNameOptions.length > visibleNameOptions.length && ( + + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.parent && ( +
+
+ {renderSectionSearch('parent', filterSectionSearch.parent, (value) => + setFilterSectionSearch((prev) => ({ ...prev, parent: value })) + )} +
+ {visibleParentOptions.length === 0 ? ( +

No parent categories found.

+ ) : ( + visibleParentOptions.map((option) => + renderCheckboxOption( + draftFilters.parentIds.includes(option.id), + () => toggleDraftListValue('parentIds', option.id), + option.name + ) + ) + )} +
+ {filteredParentOptions.length > visibleParentOptions.length && ( + + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.feature && ( +
+
+ {renderCheckboxOption( + draftFilters.features.includes('enabled'), + () => toggleDraftListValue('features', 'enabled'), + 'Enabled' + )} + {renderCheckboxOption( + draftFilters.features.includes('disabled'), + () => toggleDraftListValue('features', 'disabled'), + 'Disabled' + )} +
+
+ )} +
+ +
+ + {expandedFilterSections.status && ( +
+
+ {renderCheckboxOption( + draftFilters.statuses.includes('active'), + () => toggleDraftListValue('statuses', 'active'), + 'Active' + )} + {renderCheckboxOption( + draftFilters.statuses.includes('inactive'), + () => toggleDraftListValue('statuses', 'inactive'), + 'Inactive' + )} +
+
+ )} +
+
+ +
+ + +
+
+ )} +
+
+ ); + }; + + const manageColumnsControl = ( +
+ + {showColumnsPanel && ( +
+ {COLUMN_OPTIONS.map((col) => ( +
+ + {col.label} + +
+ ))} +
+ )} +
+ ); + + const sortControl = ( +
+ + {showSortPanel && ( +
+ {SORT_OPTIONS.map((option) => { + const active = option.field === sortConfig.field && option.dir === sortConfig.dir; + return ( + + ); + })} +
+ )} +
+ ); + + const dateControl = ( +
+ + {showDatePanel && ( +
+
+
+ + setDraftDateFrom(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+ + setDraftDateTo(e.target.value)} + className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary" + /> +
+
+
+ + +
+
+ )} +
+ ); + + return ( +
+
+
+
+

Categories

+ + {categories.length} + +
+
+ +
+
+ + {showExportMenu && ( +
+ + +
+ )} +
+ + +
+
+ + {viewMode === 'grid' ? ( +
+
+
+
+ {renderFilterControl('left')} + {searchField} +
+
+ {viewToggle} + {addCategoryButton} +
+
+
+ + {loading ? ( +
+ + Loading catalog categories... +
+ ) : filteredCategories.length === 0 ? ( +
+ {searchQuery || filtersActive || dateFilterEnabled ? 'No categories match your search.' : 'No categories found.'} +
+ ) : ( +
+
+ {pager.items.map((c) => { + const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id)); + return ( +
+
+ setOpenActionId(open ? c.category_id : null)} + onEdit={() => handleOpenEdit(c)} + onDelete={() => openDeleteModal(c.category_id, c.name)} + /> +
+
+
+ +
+
+

{c.name}

+

{c.slug}

+
+
+
+

Parent: {parent && !isRootParentId(c.parent_category_id) ? parent.name : '- Root -'}

+

{coerceFlag(c.is_parent_feature) ? 'Parent Feature enabled' : 'Parent Feature disabled'}

+
+
+ {renderStatusBadge(coerceFlag(c.is_active))} +
+
+ ); + })} +
+
+ )} + +
+ ) : ( +
+
+
+ {searchField} + {addCategoryButton} +
+
+
+ {sortControl} + {dateControl} +
+
+ {renderFilterControl('right')} + {manageColumnsControl} + {viewToggle} +
+
+
+ + {loading ? ( +
+ + Loading catalog categories... +
+ ) : ( +
+ + + + {visibleColumns.categoryName && ( + + )} + {visibleColumns.slug && ( + + )} + {visibleColumns.parentCategory && ( + + )} + {visibleColumns.parentFeature && ( + + )} + {visibleColumns.sortOrder && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + + + {filteredCategories.length === 0 ? ( + + + + ) : ( + pager.items.map((c) => { + const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id)); + const parentLabel = parent && !isRootParentId(c.parent_category_id) ? parent.name : '- Root -'; + return ( + + {visibleColumns.categoryName && ( + + )} + {visibleColumns.slug && ( + + )} + {visibleColumns.parentCategory && ( + + )} + {visibleColumns.parentFeature && ( + + )} + {visibleColumns.sortOrder && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.actions && ( + + )} + + ); + }) + )} + +
+ {renderSortLabel('Category Name', 'name')} + + {renderSortLabel('Slug / Path', 'slug')} + + {renderSortLabel('Parent Category', 'parent')} + + {renderSortLabel('Parent Feature')} + + {renderSortLabel('Sort Order', 'sortOrder')} + + {renderSortLabel('Status', 'status')} + + {renderSortLabel('Action')} +
+ {searchQuery || filtersActive || dateFilterEnabled ? 'No categories match your search.' : 'No categories found.'} +
+
+
+ +
+
+

{c.name}

+

{c.slug}

+
+
+
+ {c.slug} + {parentLabel} + {renderFeatureBadge(coerceFlag(c.is_parent_feature))} + {c.sort_order}{renderStatusBadge(coerceFlag(c.is_active))} +
+ setOpenActionId(open ? c.category_id : null)} + onEdit={() => handleOpenEdit(c)} + onDelete={() => openDeleteModal(c.category_id, c.name)} + /> +
+
+
+ )} + +
+ )} + + {deleteTarget && ( +
+
e.stopPropagation()} + > +
+
+
+ +
+
+

+ Delete Category +

+

+ Are you sure you want to delete{' '} + "{deleteTarget.name}" and all its + sub-categories and products? +

+
+
+ +
+
+ + +
+
+
+ )} + + } + > +
+
+
+ + setCategoryName(e.target.value)} + className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors" + /> +
+ +
+ + cat.category_id !== editingCategory?.category_id) + .map((cat) => ({ value: cat.category_id, label: cat.name })), + ]} + /> +
+ +
+ +