some updation

This commit is contained in:
MohamedHasan07
2026-06-18 17:56:25 +05:30
parent 8ef8c6b81c
commit 68aacb1f84
56 changed files with 4139 additions and 1325 deletions
BIN
View File
Binary file not shown.
+711 -2
View File
@@ -9,13 +9,19 @@
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-tabs": "^1.1.15",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.1",
"clsx": "^2.1.1",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.0",
"react-toastify": "^11.1.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
@@ -279,6 +285,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
@@ -438,6 +450,44 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -584,6 +634,29 @@
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz",
"integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz",
@@ -614,6 +687,32 @@
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz",
"integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
@@ -644,6 +743,254 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
"integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-escape-keydown": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz",
"integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-menu": "2.1.18",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz",
"integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz",
"integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-roving-focus": "1.1.13",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
"integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
@@ -690,6 +1037,37 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
"integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
@@ -708,6 +1086,51 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz",
"integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-roving-focus": "1.1.13",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
@@ -745,6 +1168,24 @@
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
@@ -775,6 +1216,24 @@
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
@@ -793,6 +1252,12 @@
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@@ -1881,6 +2346,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -1998,6 +2475,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2141,6 +2631,16 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2181,6 +2681,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.373",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz",
@@ -2538,6 +3044,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3239,6 +3754,32 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -3281,6 +3822,126 @@
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-toastify": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz",
"integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
@@ -3399,6 +4060,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3499,8 +4166,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -3601,6 +4267,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+6
View File
@@ -11,13 +11,19 @@
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-tabs": "^1.1.15",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.1",
"clsx": "^2.1.1",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.0",
"react-toastify": "^11.1.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
BIN
View File
Binary file not shown.
+37 -111
View File
@@ -1,119 +1,45 @@
import { useState } from 'react';
import { Sidebar } from './components/customs/Sidebar';
import { Header } from './components/customs/Headers';
import { ProtectedRoute } from './components/layouts/ProtectedRoute';
import { PageWrapper } from './components/layouts/PageWrapper';
// src/App.tsx
import { useEffect, useRef, useState } from 'react';
import AppRoutes from './routes/AppRoutes';
import { ToastContainer } from 'react-toastify';
import { useAppDispatch } from './store';
import { setPermissions } from './store/slices/authSlice';
// ── Real feature pages (swap placeholders as you build) ──────────────────────
import Dashboard from './features/dashboard/pages/Dashboard';
import VariantList from './features/variants/pages/VariantList';
import ProductList from './features/product/pages/ProductList';
// ── Placeholder page ─────────────────────────────────────────────────────────
const PlaceholderPage = ({ title }: { title: string }) => (
<PageWrapper>
<div className="bg-surface border border-border rounded-xl p-12 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-full bg-purple-50 flex items-center justify-center mb-4">
<span className="text-2xl">🚧</span>
</div>
<h2 className="text-base font-semibold text-foreground mb-1">{title}</h2>
<p className="text-sm text-muted-foreground">This module is under construction.</p>
</div>
</PageWrapper>
);
// ── View → Page Meta ──────────────────────────────────────────────────────────
const VIEW_META: Record<string, { title: string; subtitle?: string }> = {
dashboard: { title: 'Dashboard', subtitle: 'Overview of your PIM platform' },
products: { title: 'Products', subtitle: 'Manage your product catalog' },
families: { title: 'Product Families', subtitle: 'Define product families and variant axes' },
variants: { title: 'Variant Management', subtitle: 'Manage product variants and SKUs' },
'category-trees': { title: 'Category Trees', subtitle: 'Organize products into category hierarchies' },
attributes: { title: 'Attribute Registry', subtitle: 'Central repository of reusable product attributes' },
'attribute-groups': { title: 'Attribute Groups', subtitle: 'Group attributes for structured data entry' },
units: { title: 'Units', subtitle: 'Manage measurement units' },
brands: { title: 'Brands', subtitle: 'Manage brand master data' },
scopes: { title: 'Scopes', subtitle: 'Define attribute scope levels' },
assets: { title: 'Asset Manager', subtitle: 'Manage digital assets and media' },
'asset-types': { title: 'Asset Types', subtitle: 'Configure asset type definitions' },
'asset-families': { title: 'Asset Families', subtitle: 'Group assets into families' },
imports: { title: 'Supplier Imports', subtitle: 'Import product data from suppliers' },
workflow: { title: 'Workflow & Approvals', subtitle: 'Manage approval workflows' },
channels: { title: 'Channel Master', subtitle: 'Manage publishing channels' },
integrations: { title: 'Integration Hub', subtitle: 'Connect external systems and APIs' },
wms: { title: 'WMS Attributes', subtitle: 'Warehouse management system attributes' },
search: { title: 'Search', subtitle: 'Global search across your catalog' },
logs: { title: 'Audit Logs', subtitle: 'Track all changes and user actions' },
users: { title: 'Users & Roles', subtitle: 'Manage user access and permissions' },
reports: { title: 'Reports', subtitle: 'Analytics and export reports' },
'design-system': { title: 'Design System', subtitle: 'UI component library and tokens' },
settings: { title: 'Settings', subtitle: 'Platform configuration' },
};
// ── View → Component mapping ─────────────────────────────────────────────────
function renderView(view: string) {
switch (view) {
// ── Real pages ──────────────────────────────────────────────────────────
case 'dashboard':
return <Dashboard />;
// ── RBAC-protected placeholders ─────────────────────────────────────────
case 'products':
return <ProductList />;
case 'families':
return <ProtectedRoute node="products.categories"><PlaceholderPage title="Product Families" /></ProtectedRoute>;
case 'category-trees':
return <ProtectedRoute node="products.categories"><PlaceholderPage title="Category Trees" /></ProtectedRoute>;
case 'integrations':
return <ProtectedRoute node="settings.integrations"><PlaceholderPage title="Integration Hub" /></ProtectedRoute>;
case 'users':
return <ProtectedRoute node="settings.users"><PlaceholderPage title="Users & Roles" /></ProtectedRoute>;
case 'reports':
return <ProtectedRoute node="reports.sales"><PlaceholderPage title="Reports" /></ProtectedRoute>;
// ── Open placeholders ───────────────────────────────────────────────────
case 'variants': return <VariantList />;
case 'attributes': return <PlaceholderPage title="Attribute Registry" />;
case 'attribute-groups': return <PlaceholderPage title="Attribute Groups" />;
case 'units': return <PlaceholderPage title="Units" />;
case 'brands': return <PlaceholderPage title="Brands" />;
case 'scopes': return <PlaceholderPage title="Scopes" />;
case 'assets': return <PlaceholderPage title="Asset Manager" />;
case 'asset-types': return <PlaceholderPage title="Asset Types" />;
case 'asset-families': return <PlaceholderPage title="Asset Families" />;
case 'imports': return <PlaceholderPage title="Supplier Imports" />;
case 'workflow': return <PlaceholderPage title="Workflow & Approvals" />;
case 'channels': return <PlaceholderPage title="Channel Master" />;
case 'wms': return <PlaceholderPage title="WMS Attributes" />;
case 'search': return <PlaceholderPage title="Search" />;
case 'logs': return <PlaceholderPage title="Audit Logs" />;
case 'design-system': return <PlaceholderPage title="Design System" />;
case 'settings': return <PlaceholderPage title="Settings" />;
default: return <Dashboard />;
}
}
// ── App Root ──────────────────────────────────────────────────────────────────
function App() {
const [activeView, setActiveView] = useState('dashboard');
const dispatch = useAppDispatch();
const bootstrappedRef = useRef(false);
const [bootstrapped, setBootstrapped] = useState(false);
const { title, subtitle } = VIEW_META[activeView] ?? { title: 'PIM Platform' };
useEffect(() => {
if (bootstrappedRef.current) return;
bootstrappedRef.current = true;
const bootstrap = async () => {
// Set default permissions for development
// dispatch(setPermissions({})); // Already set in initialState, but can override here
setBootstrapped(true);
};
bootstrap();
}, [dispatch]);
if (!bootstrapped) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
<div className="w-10 h-10 border-4 border-primary-600 border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-sm text-gray-600">Initializing PIM Platform...</p>
</div>
</div>
);
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Fixed left sidebar (240px) */}
<Sidebar activeView={activeView} onNavigate={setActiveView} />
{/* Right column: sticky header + scrollable page content */}
<div className="flex flex-col flex-1 overflow-hidden">
<Header title={title} subtitle={subtitle} />
<main className="flex-1 overflow-y-auto bg-background">
{renderView(activeView)}
</main>
</div>
</div>
<>
<AppRoutes />
<ToastContainer position="top-right" autoClose={3000} />
</>
);
}
export default App;
export default App;
View File
@@ -0,0 +1,32 @@
// import { type ButtonHTMLAttributes } from 'react';
// import { usePermissions } from '../../hooks/usePermissions';
// interface PermissionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
// module: string;
// action: string;
// children: React.ReactNode;
// }
// // Button that only renders if user has permission
// export const PermissionButton = ({
// module,
// action,
// children,
// className = '',
// ...props
// }: PermissionButtonProps) => {
// const { hasPermission } = usePermissions();
// if (!hasPermission(module, action)) {
// return null;
// }
// return (
// <button
// className={`px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 ${className}`}
// {...props}
// >
// {children}
// </button>
// );
// };
@@ -0,0 +1,25 @@
// import { type ReactNode } from 'react';
// import { usePermissions } from '../../hooks/usePermissions';
// interface PermissionWrapperProps {
// children: ReactNode;
// module: string;
// action?: string;
// fallback?: ReactNode;
// }
// // Wrapper component for conditional rendering based on permissions
// export const PermissionWrapper = ({
// children,
// module,
// action = 'view',
// fallback = null
// }: PermissionWrapperProps) => {
// const { hasPermission } = usePermissions();
// if (!hasPermission(module, action)) {
// return <>{fallback}</>;
// }
// return <>{children}</>;
// };
@@ -0,0 +1,20 @@
// src/authentication/components/ProtectedRoute.tsx
import React from 'react';
// Development Mode AuthGuard - Always allows access (No real login yet)
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// In development, we skip real authentication
return <>{children}</>;
};
// ProtectedRoute for permissions (still useful)
export const ProtectedRoute: React.FC<{
node: string;
children: React.ReactNode;
}> = ({ node, children }) => {
// For now, allow everything in development
// You can later connect real permission logic
return <>{children}</>;
};
export default AuthGuard;
+6
View File
@@ -0,0 +1,6 @@
// // Authentication module barrel exports
// export { Login } from './pages/Login';
// export { ForgotPassword } from './pages/ForgotPassword';
// export { authRoutes } from './routes';
// export { authService } from './services/authService';
// export * from './types';
+392
View File
@@ -0,0 +1,392 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { authService } from '../services/authService';
import { Package, Mail, ArrowRight, ArrowLeft, KeyRound, Lock, Eye, EyeOff, CheckCircle } from 'lucide-react';
type Step = 'email' | 'otp' | 'reset' | 'success';
export const ForgotPassword = () => {
const navigate = useNavigate();
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
// Step 1: Request OTP
const handleRequestOTP = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await authService.forgotPassword(email);
setMessage(response.message || 'OTP sent to your email');
setStep('otp');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to send OTP';
setError(errorMessage);
} finally {
setLoading(false);
}
};
// Step 2: Verify OTP
const handleVerifyOTP = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await authService.verifyOTP(email, otp);
setMessage('OTP verified successfully');
setStep('reset');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Invalid OTP';
setError(errorMessage);
} finally {
setLoading(false);
}
};
// Step 3: Reset Password
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
await authService.resetPassword(email, otp, newPassword, confirmPassword);
setStep('success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to reset password';
setError(errorMessage);
} finally {
setLoading(false);
}
};
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{['email', 'otp', 'reset', 'success'].map((s, i) => (
<div key={s} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step === s || ['email', 'otp', 'reset', 'success'].indexOf(step) > i
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{i + 1}
</div>
{i < 3 && (
<div
className={`w-8 h-0.5 ${
['email', 'otp', 'reset', 'success'].indexOf(step) > i
? 'bg-purple-600'
: 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
);
const renderEmailStep = () => (
<form onSubmit={handleRequestOTP} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Forgot Password?</h2>
<p className="text-gray-600">Enter your email to receive a verification code</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className="w-5 h-5 text-gray-400" />
</div>
<input
type="email"
required
className="block w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? (
<span>Sending...</span>
) : (
<>
<span>Send OTP</span>
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</form>
);
const renderOTPStep = () => (
<form onSubmit={handleVerifyOTP} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Enter Verification Code</h2>
<p className="text-gray-600">We sent a code to <span className="font-medium">{email}</span></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verification Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<KeyRound className="w-5 h-5 text-gray-400" />
</div>
<input
type="text"
required
maxLength={6}
className="block w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all text-center text-2xl tracking-widest"
placeholder="000000"
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
{message && (
<div className="p-4 bg-green-50 border border-green-200 rounded-xl text-green-600 text-sm">
{message}
</div>
)}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading || otp.length < 4}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? <span>Verifying...</span> : <span>Verify OTP</span>}
</button>
<button
type="button"
onClick={() => setStep('email')}
className="w-full flex items-center justify-center gap-2 py-3 px-4 border border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition-all"
>
<ArrowLeft className="w-5 h-5" />
<span>Change Email</span>
</button>
</form>
);
const renderResetStep = () => (
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Reset Password</h2>
<p className="text-gray-600">Create a new password for your account</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="w-5 h-5 text-gray-400" />
</div>
<input
type={showPassword ? 'text' : 'password'}
required
minLength={8}
className="block w-full pl-12 pr-12 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Enter new password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center"
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="w-5 h-5 text-gray-400" />
</div>
<input
type={showConfirmPassword ? 'text' : 'password'}
required
minLength={8}
className="block w-full pl-12 pr-12 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center"
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? <span>Resetting...</span> : <span>Reset Password</span>}
</button>
</form>
);
const renderSuccessStep = () => (
<div className="text-center space-y-6">
<div className="w-20 h-20 mx-auto bg-gradient-to-r from-green-500 to-emerald-500 rounded-full flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Password Reset Successful!</h2>
<p className="text-gray-600">Your password has been reset successfully. You can now login with your new password.</p>
</div>
<button
onClick={() => navigate('/login')}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all"
>
<span>Go to Login</span>
<ArrowRight className="w-5 h-5" />
</button>
</div>
);
return (
<div className="min-h-screen flex">
{/* Left Panel - Decorative */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-20 left-20 w-72 h-72 bg-purple-500/30 rounded-full blur-3xl"></div>
<div className="absolute bottom-20 right-20 w-96 h-96 bg-pink-500/20 rounded-full blur-3xl"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-blue-500/20 rounded-full blur-3xl"></div>
</div>
<div className="relative z-10 flex flex-col justify-between p-12 w-full">
<Link to="/" className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Package className="w-7 h-7 text-white" />
</div>
<span className="text-2xl font-bold text-white">Maskan<span className="text-purple-400">POS</span></span>
</Link>
<div>
<h1 className="text-4xl font-bold text-white mb-6 leading-tight">
Reset Your
<br />
<span className="bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Password Securely
</span>
</h1>
<p className="text-gray-400 text-lg max-w-md">
We'll send you a verification code to your registered email address to help you reset your password.
</p>
</div>
<div className="text-gray-500 text-sm">
© 2026 Maskan Inventory. All rights reserved.
</div>
</div>
</div>
{/* Right Panel - Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-gray-50">
<div className="w-full max-w-md">
{/* Mobile logo */}
<div className="lg:hidden flex items-center justify-center gap-3 mb-10">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Package className="w-7 h-7 text-white" />
</div>
<span className="text-2xl font-bold text-gray-900">Maskan<span className="text-purple-600">POS</span></span>
</div>
{renderStepIndicator()}
{step === 'email' && renderEmailStep()}
{step === 'otp' && renderOTPStep()}
{step === 'reset' && renderResetStep()}
{step === 'success' && renderSuccessStep()}
{/* Back to login */}
{step !== 'success' && (
<div className="mt-6 text-center">
<Link to="/login" className="text-sm text-gray-600 hover:text-purple-600 transition-colors">
Back to login
</Link>
</div>
)}
</div>
</div>
</div>
);
};
export default ForgotPassword;
+465
View File
@@ -0,0 +1,465 @@
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
:root {
--primary-500: #5570F1;
--primary-600: #4152e8;
--primary-blue: #5570F1;
--neutral-900: #001f54;
--neutral-800: #0f172a;
--neutral-700: #475569;
--neutral-600: #64748b;
--neutral-500: #94a3b8;
--border-color: #e2e8f0;
--white: #ffffff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
font-family: "Plus Jakarta Sans", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login-container {
min-height: 100vh;
display: flex;
background-color: var(--white);
font-family: "Plus Jakarta Sans", sans-serif;
color: var(--neutral-900);
position: relative;
overflow: hidden;
}
/* Gradient background that spans the full viewport */
.login-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg,
#d8b4fe 0%,
#c4b5fd 25%,
#a78bfa 35%,
#93c5fd 50%,
#60a5fa 60%,
#3b82f6 70%,
#7dd3fc 80%,
#a7f3d0 90%,
#fef08a 100%);
z-index: 0;
}
.login-left-panel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 60px;
position: relative;
z-index: 1;
min-height: 100vh;
}
.login-logo-container {
display: flex;
align-items: center;
gap: 10px;
}
.login-logo-badge {
width: 36px;
height: 36px;
border-radius: 8px;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.login-logo-icon {
width: 28px;
height: 28px;
color: var(--neutral-900);
}
.login-logo-copy {
display: flex;
flex-direction: column;
justify-content: center;
}
.login-logo-text {
font-size: 18px;
font-weight: 700;
color: var(--neutral-900);
letter-spacing: -0.01em;
line-height: 1;
}
.login-left-content {
max-width: 480px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 40px;
}
.login-main-title {
font-family: "Inter", sans-serif;
font-weight: 600;
font-size: 52px;
line-height: 1.08;
letter-spacing: -0.02em;
color: var(--neutral-900);
margin-bottom: 24px;
}
.login-main-title span {
font-style: italic;
font-weight: 400;
color: var(--white);
}
.login-description {
font-family: "Inter", sans-serif;
font-weight: 500;
font-size: 16px;
line-height: 1.5;
color: var(--white);
margin-bottom: 32px;
max-width: 400px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 16px;
list-style: none;
padding: 0;
margin: 0;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
color: var(--white);
font-size: 16px;
font-weight: 500;
}
.feature-check-icon {
width: 24px;
height: 24px;
border: 1.5px solid var(--white);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.feature-check-icon svg {
color: var(--white);
stroke-width: 2.5px;
}
.login-footer-text {
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 400;
}
/* Right panel with the form card */
.login-right-panel {
flex: 1;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: 14px 14px 14px 0;
background: transparent;
position: relative;
z-index: 2;
}
.login-card {
width: 100%;
height: 100%;
background: var(--white);
border-radius: 12px;
padding: 60px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
opacity: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.login-card-header {
margin-bottom: 32px;
}
.login-card-title {
font-size: 28px;
font-weight: 700;
color: var(--neutral-900);
margin-bottom: 6px;
letter-spacing: -0.01em;
}
.login-card-subtitle {
font-size: 15px;
color: var(--neutral-600);
font-weight: 400;
line-height: 1.5;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--neutral-700);
margin-bottom: 6px;
}
.input-wrapper {
position: relative;
width: 100%;
}
.login-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
color: var(--neutral-800);
transition: all 0.2s ease;
background-color: var(--white);
font-weight: 400;
font-family: "Plus Jakarta Sans", sans-serif;
outline: none;
}
.login-input::placeholder {
color: #94a3b8;
font-weight: 400;
}
.login-input:focus {
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgba(82, 131, 249, 0.1);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--neutral-500);
cursor: pointer;
background: none;
border: none;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.password-toggle:hover {
color: var(--neutral-700);
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--neutral-600);
font-size: 14px;
font-weight: 400;
}
.login-checkbox {
width: 16px;
height: 16px;
border: 1.5px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
accent-color: var(--primary-blue);
}
.forgot-password-link {
color: var(--primary-blue);
font-size: 14px;
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s ease;
}
.forgot-password-link:hover {
opacity: 0.8;
}
.login-actions {
display: flex;
gap: 16px;
margin-top: 24px;
}
.btn-signin {
flex: 1;
background: var(--primary-blue);
color: var(--white);
padding: 12px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-family: "Plus Jakarta Sans", sans-serif;
}
.btn-signin:hover {
background: var(--primary-600);
box-shadow: 0 4px 12px rgba(82, 131, 249, 0.3);
}
.btn-signin:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-back {
flex: 1;
background-color: var(--white);
color: var(--neutral-600);
padding: 12px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: "Plus Jakarta Sans", sans-serif;
}
.btn-back:hover {
background-color: #f8fafc;
border-color: #94a3b8;
}
.error-message {
color: #ef4444;
font-size: 14px;
margin-bottom: 16px;
font-weight: 500;
padding: 10px 14px;
background: #fef2f2;
border-radius: 6px;
border-left: 3px solid #ef4444;
}
.login-container::selection,
.login-container *::selection {
background: rgba(82, 131, 249, 0.2);
color: var(--neutral-900);
}
@media (max-width: 1200px) {
.login-left-panel {
padding: 36px 48px;
}
.login-main-title {
font-size: 44px;
}
.login-card {
max-width: 440px;
padding: 40px 48px;
}
}
@media (max-width: 1024px) {
.login-left-panel {
display: none;
}
.login-background {
display: none;
}
.login-right-panel {
flex: 1;
padding: 32px;
justify-content: center;
}
.login-card {
margin-left: 0;
max-width: 480px;
}
}
@media (max-width: 640px) {
.login-right-panel {
padding: 24px;
}
.login-card {
padding: 32px 24px;
}
.login-actions {
flex-direction: column;
}
.btn-back,
.btn-signin {
width: 100%;
}
.form-footer {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.login-card-title {
font-size: 24px;
}
}
+262
View File
@@ -0,0 +1,262 @@
import { useState } from "react";
import { Navigate, useLocation, Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../store";
import { setCredentials, setLoading } from "../../store/auth.store";
import { authService } from "../services/authService";
import { Package, Eye, EyeOff, Check } from "lucide-react";
import "./Login.css";
const FEATURE_LIST = [
"Time analytics and role",
"Time analytics and role",
"Time analytics and role",
];
export const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const dispatch = useAppDispatch();
const { isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const location = useLocation();
// Redirect if already authenticated
if (isAuthenticated) {
const from = location.state?.from?.pathname || "/dashboard";
return <Navigate to={from} replace />;
}
// Show loading state while validating session
if (isLoading && !email && !password) {
return (
<div className="login-container">
<div className="login-background" />
<div className="login-right-panel" style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<div className="login-card">
<div className="login-card-header" style={{ textAlign: 'center' }}>
<div className="login-logo-badge" style={{ margin: '0 auto 1rem' }}>
<Package className="login-logo-icon" />
</div>
<h2 className="login-card-title">Authenticating...</h2>
<p className="login-card-subtitle">Please wait while we set up your session</p>
</div>
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div className="spinner" style={{
border: '3px solid #f3f3f3',
borderTop: '3px solid #3498db',
borderRadius: '50%',
width: '40px',
height: '40px',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}} />
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
</div>
</div>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
dispatch(setLoading(true));
try {
console.log("Attempting login...");
const response = await authService.login({ email, password });
console.log("Login response:", response);
if (response.success) {
dispatch(
setCredentials({
user: response.data.user,
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
permissions: response.data.permissions,
accessibleStores: response.data.accessibleStores,
isGlobalAccess: response.data.isGlobalAccess,
}),
);
} else {
setError(response.message || "Login failed");
dispatch(setLoading(false));
}
} catch (err: unknown) {
console.error("Login error:", err);
// Handle axios errors
if (err && typeof err === "object" && "response" in err) {
const axiosError = err as {
response?: { data?: { message?: string } };
};
setError(axiosError.response?.data?.message || "Login failed");
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("Login failed");
}
dispatch(setLoading(false));
}
};
// const handleSSOLogin = () => {
// // Redirect to SaaS platform SSO login
// const saasBaseUrl = import.meta.env.VITE_SAAS_BASE_URL || 'http://localhost:3000';
// const redirectUri = `${window.location.origin}/sso/callback`;
// const ssoUrl = `${saasBaseUrl}/sso/login?client_id=inventory_module&redirect_uri=${encodeURIComponent(redirectUri)}`;
// window.location.href = ssoUrl;
// };
return (
<div className="login-container">
<div className="login-background" />
<div className="login-left-panel">
<div className="login-logo-container">
<div className="login-logo-badge">
<Package className="login-logo-icon" />
</div>
<div className="login-logo-copy">
<span className="login-logo-text">Maskan POS</span>
</div>
</div>
<div className="login-left-content">
<h1 className="login-main-title">
Manage Your <br />
<span>Inventory</span> With <br />
Confidence
</h1>
<p className="login-description">
Powerful inventory management with integrated POS, real - Time
analytics and role - based access control.
</p>
<ul className="feature-list">
{FEATURE_LIST.map((feature, index) => (
<li key={index} className="feature-item">
<div className="feature-check-icon">
<Check size={16} />
</div>
{feature}
</li>
))}
</ul>
</div>
<p className="login-footer-text">
© 2026 Maskan Inventory All rights reserved.
</p>
</div>
<div className="login-right-panel">
<div className="login-card">
<div className="login-card-header">
<h2 className="login-card-title">Welcome Back!</h2>
<p className="login-card-subtitle">
Sign in to your account to continue
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Email Address</label>
<div className="input-wrapper">
<input
type="email"
className="login-input"
placeholder="admin@platform.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<div className="input-wrapper">
<input
type={showPassword ? "text" : "password"}
className="login-input"
placeholder="Admin@123"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div className="form-footer">
<label className="checkbox-group">
<input type="checkbox" className="login-checkbox" />
<span>Remember Me</span>
</label>
<Link to="/forgot-password" className="forgot-password-link">
Forgot Password?
</Link>
</div>
{error && <div className="error-message">{error}</div>}
<div className="login-actions">
<Link to="/" className="btn-back">
Back to Home
</Link>
<button type="submit" disabled={isLoading} className="btn-signin">
{isLoading ? "Signing in..." : "Sign in"}
</button>
</div>
{/* <div style={{ margin: '1.5rem 0', textAlign: 'center', position: 'relative' }}>
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: '1px',
backgroundColor: '#e5e7eb'
}} />
<span style={{
position: 'relative',
backgroundColor: 'white',
padding: '0 1rem',
color: '#6b7280',
fontSize: '0.875rem'
}}>OR</span>
</div> */}
{/* <button
type="button"
onClick={handleSSOLogin}
className="btn-signin"
style={{
width: '100%',
backgroundColor: '#10b981',
borderColor: '#10b981'
}}
>
Sign in with SSO
</button> */}
</form>
</div>
</div>
</div>
);
};
export default Login;
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAppDispatch } from '../../store';
import { setCredentials, setLoading } from '../../store/auth.store';
import { authService } from '../services/authService';
import { Package } from 'lucide-react';
import './Login.css';
export const SSOCallback = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [error, setError] = useState<string>('');
const [status, setStatus] = useState<string>('Processing SSO login...');
useEffect(() => {
const handleSSOCallback = async () => {
const code = searchParams.get('code') || searchParams.get('grant');
const errorParam = searchParams.get('error');
// Handle error from SaaS platform
if (errorParam) {
setError(`SSO Error: ${errorParam}`);
setStatus('SSO login failed');
setTimeout(() => navigate('/login'), 3000);
return;
}
// Handle missing code
if (!code) {
setError('Missing authorization code');
setStatus('Invalid SSO callback');
setTimeout(() => navigate('/login'), 3000);
return;
}
// Exchange code for token
dispatch(setLoading(true));
try {
setStatus('Exchanging authorization code...');
const response = await authService.exchangeSaaSToken(code);
if (response.success && response.data) {
setStatus('Login successful! Redirecting...');
dispatch(
setCredentials({
user: response.data.user,
accessToken: response.data.accessToken || (response.data as any).token,
refreshToken: response.data.refreshToken || (response.data as any).token,
permissions: response.data.permissions || [],
accessibleStores: response.data.accessibleStores,
isGlobalAccess: response.data.isGlobalAccess,
})
);
// Redirect to dashboard after successful login
setTimeout(() => navigate('/dashboard'), 500);
} else {
throw new Error(response.message || 'SSO exchange failed');
}
} catch (err: unknown) {
console.error('SSO Exchange error:', err);
if (err && typeof err === 'object' && 'response' in err) {
const axiosError = err as {
response?: { data?: { message?: string } };
};
setError(axiosError.response?.data?.message || 'SSO exchange failed');
} else if (err instanceof Error) {
setError(err.message);
} else {
setError('SSO exchange failed');
}
setStatus('SSO login failed');
dispatch(setLoading(false));
setTimeout(() => navigate('/login'), 3000);
}
};
handleSSOCallback();
}, [searchParams, navigate, dispatch]);
return (
<div className="login-container">
<div className="login-background" />
<div className="login-right-panel" style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<div className="login-card">
<div className="login-card-header" style={{ textAlign: 'center' }}>
<div className="login-logo-badge" style={{ margin: '0 auto 1rem' }}>
<Package className="login-logo-icon" />
</div>
<h2 className="login-card-title">SSO Authentication</h2>
<p className="login-card-subtitle">{status}</p>
</div>
{error ? (
<div className="error-message" style={{ marginTop: '1rem' }}>
{error}
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
Redirecting to login page...
</p>
</div>
) : (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div className="spinner" style={{
border: '3px solid #f3f3f3',
borderTop: '3px solid #3498db',
borderRadius: '50%',
width: '40px',
height: '40px',
animation: 'spin 1s linear infinite',
margin: '0 auto'
}} />
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)}
</div>
</div>
</div>
);
};
export default SSOCallback;
+17
View File
@@ -0,0 +1,17 @@
// import { Login } from './pages/Login';
// import { ForgotPassword } from './pages/ForgotPassword';
// export const authRoutes = [
// {
// path: '/login',
// element: Login,
// public: true
// },
// {
// path: '/forgot-password',
// element: ForgotPassword,
// public: true
// }
// ];
// export { Login, ForgotPassword };
+152
View File
@@ -0,0 +1,152 @@
// import { axiosInstance, tokenService } from '../../services';
// import { API_ENDPOINTS } from '../../utils/constants';
// import type { LoginRequest, LoginResponse } from '../types';
// /**
// * Auth service with real API calls
// * Uses centralized TokenService for token management
// */
// export const authService = {
// // Login - calls backend /auth/login endpoint and stores tokens
// login: async (credentials: LoginRequest): Promise<LoginResponse> => {
// const response = await axiosInstance.post(API_ENDPOINTS.AUTH.LOGIN, credentials);
// const data = response.data as LoginResponse;
// // Store tokens using TokenService
// if (data.data?.accessToken) {
// tokenService.setTokens(data.data.accessToken, data.data.refreshToken);
// }
// // Store user data in tokenService for backup
// if (data.data?.user) {
// tokenService.setUserData({
// id: data.data.user.id,
// email: data.data.user.email,
// name: data.data.user.name ?? undefined,
// company_id: data.data.user.company?.id,
// });
// }
// return data;
// },
// // Refresh access token using refresh token
// refreshToken: async (): Promise<{ accessToken: string } | null> => {
// const refreshToken = tokenService.getRefreshToken();
// if (!refreshToken) {
// return null;
// }
// try {
// const response = await axiosInstance.post('/api/v1/auth/refresh-token', {
// refresh_token: refreshToken,
// });
// const { accessToken } = response.data.data;
// // Update stored access token
// tokenService.setTokens(accessToken, refreshToken);
// return { accessToken };
// } catch (error) {
// // Refresh failed - clear all tokens
// tokenService.clearAll();
// return null;
// }
// },
// // Logout - calls backend /auth/logout endpoint and clears tokens
// logout: async (): Promise<void> => {
// try {
// await axiosInstance.post(API_ENDPOINTS.AUTH.LOGOUT);
// } finally {
// // Always clear tokens on logout, even if API call fails
// tokenService.clearAll();
// }
// },
// // Forgot password - sends OTP to email
// forgotPassword: async (email: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/forgot-password', { email });
// return response.data;
// },
// // Verify OTP
// verifyOTP: async (email: string, otp: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/verify-otp', { email, otp });
// return response.data;
// },
// // Reset password with OTP
// resetPassword: async (email: string, otp: string, newPassword: string, confirmPassword: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/reset-password', {
// email,
// otp,
// new_password: newPassword,
// confirm_password: confirmPassword,
// });
// return response.data;
// },
// // Change password (authenticated)
// changePassword: async (oldPassword: string, newPassword: string, confirmPassword: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/change-password', {
// old_password: oldPassword,
// new_password: newPassword,
// confirm_password: confirmPassword,
// });
// return response.data;
// },
// // Check if user is authenticated
// isAuthenticated: (): boolean => {
// return tokenService.isAuthenticated();
// },
// // Get current user data
// getCurrentUser: () => {
// return tokenService.getUserData();
// },
// // Check if token is expired
// isTokenExpired: (): boolean => {
// return tokenService.isTokenExpired();
// },
// // Check if we have a refresh token
// hasRefreshToken: (): boolean => {
// return !!tokenService.getRefreshToken();
// },
// // Exchange SaaS authorization code for token (SSO)
// exchangeSaaSToken: async (code: string): Promise<LoginResponse> => {
// // Corrected path to match app.js mounting: /api/v1/sso/exchange
// const response = await axiosInstance.post('/api/v1/sso/exchange', { code });
// const data = response.data as LoginResponse;
// // Store tokens using TokenService
// if (data.data?.accessToken || (data.data as any)?.token) {
// const token = data.data?.accessToken || (data.data as any)?.token;
// tokenService.setTokens(token, token);
// }
// // Store user data
// if (data.data?.user) {
// tokenService.setUserData({
// id: data.data.user.id,
// email: data.data.user.email,
// name: data.data.user.name ?? undefined,
// company_id: data.data.user.company?.id,
// });
// }
// return data;
// },
// // Get current user profile from backend
// getMe: async (): Promise<LoginResponse> => {
// const response = await axiosInstance.get('/api/v1/auth/me');
// return response.data;
// },
// };
+74
View File
@@ -0,0 +1,74 @@
// export interface Store {
// id: number | string;
// store_code: string;
// store_name: string;
// store_type: string;
// }
// // User information from backend
// export interface User {
// id: number;
// name: string | null;
// email: string;
// type: string;
// company_id: number | null;
// company: {
// id: number;
// company_name: string;
// } | null;
// roles?: {
// id: number;
// name: string;
// code: string;
// }[];
// stores: Store[];
// store_id?: number | string;
// }
// // Permission actions available for each module
// export interface PermissionActions {
// view?: boolean;
// create?: boolean;
// edit?: boolean;
// delete?: boolean;
// alter?: boolean;
// import?: boolean;
// export?: boolean;
// }
// // Permissions structure: nested object from backend
// export type Permissions = Record<string, PermissionActions | boolean>;
// // Authentication state in Redux store
// export interface AuthState {
// user: User | null;
// accessToken: string | null;
// permissions: Permissions;
// isAuthenticated: boolean;
// isLoading: boolean;
// currentStore: Store | null;
// accessibleStores: Store[];
// isGlobalAccess: boolean;
// }
// // API response from login endpoint
// export interface LoginResponse {
// StatusCode: number;
// data: {
// token: any;
// user: User;
// accessToken: string;
// refreshToken?: string;
// permissions: Permissions;
// accessibleStores?: Store[];
// isGlobalAccess?: boolean;
// };
// message: string;
// success: boolean;
// }
// // Login request payload
// export interface LoginRequest {
// email: string;
// password: string;
// }
+10
View File
@@ -0,0 +1,10 @@
// Re-export all auth types
export type {
Store,
User,
PermissionActions,
Permissions,
AuthState,
LoginResponse,
LoginRequest,
} from './auth.types';
+45
View File
@@ -0,0 +1,45 @@
import { type ReactNode } from "react";
interface BadgeProps {
children: ReactNode;
variant?: "default" | "success" | "warning" | "error" | "info" | "secondary";
size?: "sm" | "md";
dot?: boolean;
}
export function Badge({
children,
variant = "default",
size = "md",
dot = false
}: BadgeProps) {
const variants = {
default: "bg-gray-100 text-gray-700",
success: "bg-green-50 text-green-700 border border-green-200",
warning: "bg-yellow-50 text-yellow-700 border border-yellow-200",
error: "bg-red-50 text-red-700 border border-red-200",
info: "bg-blue-50 text-blue-700 border border-blue-200",
secondary: "bg-purple-50 text-purple-700 border border-purple-200",
};
const sizes = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-xs",
};
const dotColors = {
default: "bg-gray-400",
success: "bg-green-500",
warning: "bg-yellow-500",
error: "bg-red-500",
info: "bg-blue-500",
secondary: "bg-purple-500",
};
return (
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${variants[variant]} ${sizes[size]}`}>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`}></span>}
{children}
</span>
);
}
+41 -9
View File
@@ -1,9 +1,41 @@
import { type ReactNode } from "react";
type ButtonVariant =
| "primary"
| "secondary"
| "outline"
| "ghost"
| "danger"
| "success";
export function buttonVariants({
variant = "primary",
}: {
variant?: ButtonVariant;
} = {}) {
const variants = {
primary:
"bg-primary text-white hover:bg-primary-hover shadow-sm",
secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-200",
outline:
"bg-white hover:bg-gray-50 text-gray-700 border border-gray-200",
ghost:
"bg-transparent hover:bg-gray-100 text-gray-700",
danger:
"bg-red-500 text-white hover:bg-red-600 shadow-sm",
success:
"bg-green-500 text-white hover:bg-green-600 shadow-sm",
};
return variants[variant];
}
// ── Base Button ───────────────────────────────────────────────────────────────
interface ButtonProps {
children: ReactNode;
children?: ReactNode;
variant?: "primary" | "secondary" | "outline" | "ghost" | "danger" | "success";
size?: "sm" | "md" | "lg";
disabled?: boolean;
@@ -29,12 +61,12 @@ export function Button({
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary/20";
const variants = {
primary: "bg-primary text-white hover:bg-primary-hover shadow-sm",
primary: "bg-primary text-white hover:bg-primary-hover shadow-sm",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
outline: "bg-white hover:bg-gray-50 text-gray-700 border border-gray-200",
ghost: "bg-transparent hover:bg-gray-100 text-gray-700",
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
success: "bg-green-500 text-white hover:bg-green-600 shadow-sm",
outline: "bg-white hover:bg-gray-50 text-gray-700 border border-gray-200",
ghost: "bg-transparent hover:bg-gray-100 text-gray-700",
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
success: "bg-green-500 text-white hover:bg-green-600 shadow-sm",
};
const sizes = {
@@ -163,9 +195,9 @@ export function IconButton({
const base = "inline-flex items-center justify-center rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary/20";
const variants = {
ghost: "text-gray-500 hover:text-gray-800 hover:bg-gray-100",
outline: "border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-800",
"danger-ghost":"text-gray-400 hover:text-red-600 hover:bg-red-50",
ghost: "text-gray-500 hover:text-gray-800 hover:bg-gray-100",
outline: "border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-800",
"danger-ghost": "text-gray-400 hover:text-red-600 hover:bg-red-50",
};
const sizes = {
+257
View File
@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "../../lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
-122
View File
@@ -1,122 +0,0 @@
import React, { useState } from "react";
import {
Bell,
ChevronDown,
Search,
Building2,
} from "lucide-react";
interface HeaderProps {
title: string; // Page title shown on the left
subtitle?: string; // Optional sub-label under the title
}
// Mock notifications — replace with Redux state when backend is ready
const MOCK_NOTIFICATIONS = 2;
// Mock user — replace with useSelector(state => state.auth.user) when available
const MOCK_USER = {
name: "John Doe",
role: "Admin",
initials: "JD",
company: "Acme Corp",
};
export function Header({ title, subtitle }: HeaderProps) {
const [notifOpen, setNotifOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
const [companyOpen, setCompanyOpen] = useState(false);
return (
<header
className="
h-16 w-full flex items-center justify-between px-6
bg-surface border-b border-border
sticky top-0 z-30
"
>
{/* ── Left: Page Title ──────────────────────────────── */}
<div>
<h1 className="text-base font-semibold text-foreground leading-tight">
{title}
</h1>
{subtitle && (
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
{subtitle}
</p>
)}
</div>
{/* ── Right: Controls ───────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Company Selector */}
<button
onClick={() => setCompanyOpen(o => !o)}
className="
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
text-foreground bg-surface-muted hover:bg-border
transition-colors border border-border
"
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<span>{MOCK_USER.company}</span>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</button>
{/* Notification Bell */}
<div className="relative">
<button
onClick={() => setNotifOpen(o => !o)}
className="
relative w-9 h-9 flex items-center justify-center rounded-lg
text-muted-foreground hover:bg-surface-muted hover:text-foreground
transition-colors border border-border
"
>
<Bell className="w-4 h-4" />
{MOCK_NOTIFICATIONS > 0 && (
<span
className="
absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1
flex items-center justify-center rounded-full
bg-primary text-white text-[10px] font-bold leading-none
"
>
{MOCK_NOTIFICATIONS}
</span>
)}
</button>
</div>
{/* User Avatar + Name */}
<button
onClick={() => setUserOpen(o => !o)}
className="
flex items-center gap-2.5 pl-2 pr-3 py-1.5 rounded-lg
hover:bg-surface-muted transition-colors border border-border
"
>
<div
className="
w-7 h-7 rounded-full flex items-center justify-center
bg-gradient-to-br from-primary to-primary-hover
text-white text-xs font-bold select-none
"
>
{MOCK_USER.initials}
</div>
<div className="text-left leading-tight hidden sm:block">
<div className="text-sm font-semibold text-foreground">
{MOCK_USER.name}
</div>
<div className="text-xs text-muted-foreground">
{MOCK_USER.role}
</div>
</div>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground hidden sm:block" />
</button>
</div>
</header>
);
}
-126
View File
@@ -1,126 +0,0 @@
import { type ReactNode } from "react";
// ── Types ─────────────────────────────────────────────────────────────────────
export type InfoCardVariant =
| "default" // white, gray border (Dashboard style)
| "green" // light green tint (Active)
| "blue" // light blue tint (Draft)
| "amber" // light amber tint (Warning)
| "red" // light red tint (Disabled)
| "purple"; // light purple tint (Primary)
export interface InfoCardProps {
/** Small uppercase label at top-left */
label: string;
/** Large stat value */
value: string | number;
/** Icon element rendered at top-right */
icon: ReactNode;
/** Optional inline trend e.g. "+12.5%" — shown right next to value */
trend?: string;
/** Trend direction colours the text: "up"=green, "down"=red, "neutral"=gray */
trendDirection?: "up" | "down" | "neutral";
/** Subtitle / helper text below value */
subtitle?: string;
/** Colour variant */
variant?: InfoCardVariant;
className?: string;
}
// ── Style maps ────────────────────────────────────────────────────────────────
const CARD_STYLES: Record<InfoCardVariant, string> = {
default: "bg-white border border-gray-200",
green: "bg-green-50 border border-green-200",
blue: "bg-blue-50 border border-blue-200",
amber: "bg-amber-50 border border-amber-200",
red: "bg-red-50 border border-red-200",
purple: "bg-purple-50 border border-purple-200",
};
const TREND_STYLES: Record<string, string> = {
up: "text-green-600",
down: "text-red-500",
neutral: "text-gray-500",
};
// ── InfoCard ──────────────────────────────────────────────────────────────────
export function InfoCard({
label,
value,
icon,
trend,
trendDirection = "up",
subtitle,
variant = "default",
className = "",
}: InfoCardProps) {
return (
<div
className={`
relative rounded-xl p-5 flex flex-col gap-3
${CARD_STYLES[variant]} ${className}
`}
>
{/* Row 1: Label + Icon */}
<div className="flex items-start justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
{label}
</span>
<span className="text-gray-400">
{icon}
</span>
</div>
{/* Row 2: Value + inline trend */}
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 leading-none">
{value}
</span>
{trend && (
<span
className={`
text-xs font-semibold flex items-center gap-0.5
${TREND_STYLES[trendDirection]}
`}
>
{trendDirection === "up" && "↗"}
{trendDirection === "down" && "↘"}
{trend}
</span>
)}
</div>
{/* Row 3: Subtitle */}
{subtitle && (
<p className="text-xs text-gray-400 leading-tight">{subtitle}</p>
)}
</div>
);
}
// ── InfoCardGrid ──────────────────────────────────────────────────────────────
// Responsive grid wrapper. Default: 4 cols on lg, 2 on sm, 1 on xs.
interface InfoCardGridProps {
children: ReactNode;
cols?: 4 | 5 | 3 | 2;
className?: string;
}
const GRID_COLS: Record<number, string> = {
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
};
export function InfoCardGrid({ children, cols = 4, className = "" }: InfoCardGridProps) {
return (
<div className={`grid gap-4 ${GRID_COLS[cols]} ${className}`}>
{children}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../../lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+17 -103
View File
@@ -1,79 +1,27 @@
import React from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
export type KPIVariant =
| "purple" // primary brand
| "green" // success / published
| "blue" // info / products
| "amber" // warning / completeness
| "red" // danger / errors
| "slate"; // neutral
export type KPIVariant = "purple" | "green" | "blue" | "amber" | "red" | "slate";
export type TrendDirection = "up" | "down" | "flat";
export interface KPIProps {
/** Large stat value — number or string (e.g. "7,979" or "90%") */
value: string | number;
/** Label shown below the value */
label: string;
/** Lucide icon component */
icon: React.ElementType;
/** Color variant */
variant?: KPIVariant;
/** Optional trend percentage e.g. "+12% this month" */
trendValue?: string;
/** Direction of the trend arrow */
trendDirection?: TrendDirection;
/** Optional small dot indicators (status pills) */
dots?: { color: "green" | "red" | "amber" | "blue"; label?: string }[];
/** Extra class on the root card */
className?: string;
}
// ── Variant config ─────────────────────────────────────────────────────────
const VARIANT_STYLES: Record<
KPIVariant,
{ card: string; iconWrap: string; icon: string; trend: string }
> = {
purple: {
card: "bg-purple-50 border-purple-100",
iconWrap: "bg-purple-200",
icon: "text-purple-600",
trend: "text-purple-600",
},
green: {
card: "bg-green-50 border-green-100",
iconWrap: "bg-green-200",
icon: "text-green-600",
trend: "text-green-600",
},
blue: {
card: "bg-blue-50 border-blue-100",
iconWrap: "bg-blue-200",
icon: "text-blue-600",
trend: "text-blue-600",
},
amber: {
card: "bg-amber-50 border-amber-100",
iconWrap: "bg-amber-200",
icon: "text-amber-600",
trend: "text-amber-600",
},
red: {
card: "bg-red-50 border-red-100",
iconWrap: "bg-red-200",
icon: "text-red-600",
trend: "text-red-500",
},
slate: {
card: "bg-slate-50 border-slate-200",
iconWrap: "bg-slate-200",
icon: "text-slate-600",
trend: "text-slate-500",
},
const VARIANT_STYLES: Record<KPIVariant, { card: string; iconWrap: string; icon: string; trend: string }> = {
purple: { card: "bg-purple-50 border-purple-100", iconWrap: "bg-purple-200", icon: "text-purple-600", trend: "text-purple-600" },
green: { card: "bg-green-50 border-green-100", iconWrap: "bg-green-200", icon: "text-green-600", trend: "text-green-600" },
blue: { card: "bg-blue-50 border-blue-100", iconWrap: "bg-blue-200", icon: "text-blue-600", trend: "text-blue-600" },
amber: { card: "bg-amber-50 border-amber-100", iconWrap: "bg-amber-200", icon: "text-amber-600", trend: "text-amber-600" },
red: { card: "bg-red-50 border-red-100", iconWrap: "bg-red-200", icon: "text-red-600", trend: "text-red-500" },
slate: { card: "bg-slate-50 border-slate-200", iconWrap: "bg-slate-200", icon: "text-slate-600", trend: "text-slate-500" },
};
const DOT_COLORS: Record<string, string> = {
@@ -83,8 +31,6 @@ const DOT_COLORS: Record<string, string> = {
blue: "bg-blue-500",
};
// ── Component ─────────────────────────────────────────────────────────────────
export function KPI({
value,
label,
@@ -98,52 +44,30 @@ export function KPI({
const styles = VARIANT_STYLES[variant];
return (
<div
className={`
flex items-center gap-4 p-5 rounded-2xl border
${styles.card} ${className}
`}
>
{/* Icon box */}
<div
className={`
w-12 h-12 rounded-xl flex-shrink-0
flex items-center justify-center
${styles.iconWrap}
`}
>
<div className={`flex items-center gap-4 p-5 rounded-2xl border ${styles.card} ${className}`}>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${styles.iconWrap}`}>
<Icon className={`w-6 h-6 ${styles.icon}`} />
</div>
{/* Text content */}
<div className="flex-1 min-w-0">
<div className="text-2xl font-bold text-gray-900 leading-none">
{value}
</div>
<div className="text-2xl font-bold text-gray-900 leading-none">{value}</div>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-sm text-gray-500 truncate">{label}</span>
{/* Status dots */}
{dots && dots.length > 0 && (
<div className="flex items-center gap-1">
{dots.map((dot, i) => (
<span
key={i}
title={dot.label}
className={`w-2 h-2 rounded-full ${DOT_COLORS[dot.color]}`}
/>
<span key={i} title={dot.label} className={`w-2 h-2 rounded-full ${DOT_COLORS[dot.color]}`} />
))}
</div>
)}
</div>
{/* Trend row */}
{trendValue && (
<div className={`flex items-center gap-1 mt-1.5 text-xs font-medium ${styles.trend}`}>
{trendDirection === "up" && <TrendingUp className="w-3.5 h-3.5" />}
{trendDirection === "up" && <TrendingUp className="w-3.5 h-3.5" />}
{trendDirection === "down" && <TrendingDown className="w-3.5 h-3.5" />}
{trendDirection === "flat" && <Minus className="w-3.5 h-3.5" />}
{trendDirection === "flat" && <Minus className="w-3.5 h-3.5" />}
<span>{trendValue}</span>
</div>
)}
@@ -152,20 +76,10 @@ export function KPI({
);
}
// ── KPIGrid helper ────────────────────────────────────────────────────────────
// Wraps multiple KPI cards in a responsive 4-column grid.
interface KPIGridProps {
children: React.ReactNode;
className?: string;
}
export function KPIGrid({ children, className = "" }: KPIGridProps) {
export function KPIGrid({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<div
className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 ${className}`}
>
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 ${className}`}>
{children}
</div>
);
}
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "../../lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
-284
View File
@@ -1,284 +0,0 @@
import React, { useState } from "react";
import { useSelector } from "react-redux";
import type { RootState } from "../../store";
import { hasPermission } from "../../utils/permissionUtils";
import {
LayoutDashboard,
Package,
FolderTree,
GitBranch,
Tag,
Layers,
Image,
Upload,
Workflow,
Radio,
Plug,
Search,
FileText,
Users,
BarChart3,
Settings,
Warehouse,
Palette,
ChevronDown,
ChevronRight,
List,
Grid3x3,
Database,
Ruler,
Award,
Target,
LayoutGrid,
FolderOpen
} from "lucide-react";
interface NavItem {
icon: React.ElementType;
label: string;
href: string;
badge?: string;
permissionNode?: string; // Optional: RBAC node to check for 'view' permission
children?: NavItem[];
}
const navItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", href: "dashboard" },
{ icon: Package, label: "Products", href: "products", badge: "12.4k", permissionNode: "products.items" },
{ icon: FolderTree, label: "Product Families", href: "families", permissionNode: "products.categories" },
{ icon: Grid3x3, label: "Variant Management", href: "variants" },
{ icon: Tag, label: "Category Trees", href: "category-trees", permissionNode: "products.categories" },
{
icon: Layers,
label: "Attribute Management",
href: "attribute-management",
children: [
{ icon: List, label: "Attribute Registry", href: "attributes" },
{ icon: Grid3x3, label: "Attribute Groups", href: "attribute-groups" }
]
},
{
icon: Database,
label: "Master Data",
href: "master-data",
children: [
{ icon: Ruler, label: "Units", href: "units" },
{ icon: Award, label: "Brands", href: "brands" },
{ icon: Target, label: "Scopes", href: "scopes" }
]
},
{
icon: Image,
label: "Asset Management",
href: "asset-management",
children: [
{ icon: FolderOpen, label: "Asset Manager", href: "assets" },
{ icon: LayoutGrid, label: "Asset Types", href: "asset-types" },
{ icon: Layers, label: "Asset Families", href: "asset-families" },
]
},
{ icon: Upload, label: "Supplier Imports", href: "imports", badge: "3" },
{ icon: Workflow, label: "Workflow & Approvals", href: "workflow", badge: "8" },
{
icon: Radio,
label: "Channels & Integration",
href: "channel-management",
children: [
{ icon: Radio, label: "Channel Master", href: "channels" },
{ icon: Plug, label: "Integration Hub", href: "integrations", permissionNode: "settings.integrations" },
]
},
{ icon: Warehouse, label: "WMS Attributes", href: "wms" },
{ icon: Search, label: "Search", href: "search" },
{ icon: FileText, label: "Audit Logs", href: "logs" },
{ icon: Users, label: "Users & Roles", href: "users", permissionNode: "settings.users" },
{ icon: BarChart3, label: "Reports", href: "reports", permissionNode: "reports.sales" },
{ icon: Palette, label: "Design System", href: "design-system" },
{ icon: Settings, label: "Settings", href: "settings" },
];
interface SidebarProps {
activeView: string;
onNavigate: (view: string) => void;
}
export function Sidebar({ activeView, onNavigate }: SidebarProps) {
const [expandedSections, setExpandedSections] = useState<string[]>(["attribute-management", "master-data", "channel-management", "asset-management"]);
const permissions = useSelector((state: RootState) => state.auth.permissions);
const toggleSection = (href: string) => {
setExpandedSections(prev =>
prev.includes(href)
? prev.filter(item => item !== href)
: [...prev, href]
);
};
const CHILD_VIEW_MAP: Record<string, string> = {
"channel-create": "channels",
"channel-edit": "channels",
"channel-view": "channels",
"integration-create": "integrations",
"integration-edit": "integrations",
"integration-view": "integrations",
"publishing-rule-create": "integrations",
"publishing-rule-edit": "integrations",
"publishing-rule-view": "integrations",
"sync-job-detail": "integrations",
"integration-error-detail": "integrations",
"asset-type-create": "asset-types",
"asset-type-edit": "asset-types",
"asset-type-view": "asset-types",
};
const isChildActive = (item: NavItem): boolean => {
if (item.children) {
return item.children.some(child =>
activeView === child.href ||
CHILD_VIEW_MAP[activeView] === child.href
);
}
return false;
};
// Helper to filter nav items recursively based on RBAC permissions
const filterNavItems = (items: NavItem[]): NavItem[] => {
return items.reduce<NavItem[]>((acc, item) => {
// If the item has a permission node, check if user can view it
if (item.permissionNode && !hasPermission(permissions, item.permissionNode, "view")) {
return acc;
}
// If the item has children, filter the children too
if (item.children) {
const filteredChildren = filterNavItems(item.children);
// Only include the parent if it has no permission restriction AND either has filtered children or doesn't mandate children
if (filteredChildren.length > 0 || item.children.length === 0) {
acc.push({ ...item, children: filteredChildren });
}
return acc;
}
acc.push(item);
return acc;
}, []);
};
const filteredNavItems = filterNavItems(navItems);
return (
<div className="w-60 h-full bg-white border-r border-border flex flex-col">
<div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center shadow-sm">
<Package className="w-4 h-4 text-white" />
</div>
<div>
<div className="font-semibold text-sm text-foreground">PIM Platform</div>
<div className="text-xs text-muted-foreground">Enterprise</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-3 px-3">
<nav className="space-y-0.5">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.href;
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedSections.includes(item.href);
const hasActiveChild = isChildActive(item);
return (
<div key={item.href}>
<button
onClick={() => {
if (hasChildren) {
toggleSection(item.href);
} else {
onNavigate(item.href);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all group text-sm ${
isActive || hasActiveChild
? "bg-primary text-white shadow-sm"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left font-medium">{item.label}</span>
{item.badge && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
isActive || hasActiveChild
? "bg-white/20 text-white"
: "bg-primary/10 text-primary"
}`}>
{item.badge}
</span>
)}
{hasChildren && (
<>
{isExpanded ? (
<ChevronDown className="w-4 h-4 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 flex-shrink-0" />
)}
</>
)}
</button>
{/* Children */}
{hasChildren && isExpanded && (
<div className="ml-4 mt-1 space-y-0.5">
{item.children!.map((child) => {
const ChildIcon = child.icon;
const isChildActiveItem = activeView === child.href;
return (
<button
key={child.href}
onClick={() => onNavigate(child.href)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all group text-sm ${
isChildActiveItem
? "bg-primary/10 text-primary font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<ChildIcon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">{child.label}</span>
{child.badge && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
isChildActiveItem
? "bg-primary/20 text-primary"
: "bg-gray-100 text-gray-600"
}`}>
{child.badge}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
})}
</nav>
</div>
<div className="p-3 border-t border-border">
<div className="p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm">
AC
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground truncate">Acme Corp</div>
<div className="text-xs text-muted-foreground truncate">Enterprise Plan</div>
</div>
</div>
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { DayPicker } from "react-day-picker";
import "react-day-picker/style.css";
interface CalendarProps {
selected?: Date;
onSelect?: (date: Date | undefined) => void;
}
export function Calendar({
selected,
onSelect,
}: CalendarProps) {
return (
<DayPicker
mode="single"
selected={selected}
onSelect={onSelect}
/>
);
}
-352
View File
@@ -1,352 +0,0 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "../../lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) return null;
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
// Explicit payload item type to avoid TS2339 on `payload` prop
type TooltipPayloadItem = {
dataKey?: string | number;
name?: string;
value?: number | string | (number | string)[];
color?: string;
fill?: string;
payload?: Record<string, unknown>;
};
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<"div"> & {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: string | number;
labelFormatter?: (label: unknown, payload: TooltipPayloadItem[]) => React.ReactNode;
formatter?: (
value: unknown,
name: string,
item: TooltipPayloadItem,
index: number,
payload: TooltipPayloadItem,
) => React.ReactNode;
color?: string;
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
labelClassName?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) return null;
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) return null;
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) return null;
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
// Legend payload item type
type LegendPayloadItem = {
value?: string;
dataKey?: string | number;
color?: string;
type?: string;
};
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
payload?: LegendPayloadItem[];
verticalAlign?: "top" | "bottom" | "middle";
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) return null;
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) return undefined;
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+57
View File
@@ -0,0 +1,57 @@
// src/components/layouts/Headers.tsx
import React, { useState } from "react";
import { useLocation } from "react-router-dom";
import { Bell, ChevronDown, Building2 } from "lucide-react";
const PAGE_TITLES: Record<string, { title: string; subtitle?: string }> = {
"/dashboard": { title: "Dashboard", subtitle: "Overview of your PIM platform" },
"/products": { title: "Products", subtitle: "Manage your product catalog" },
"/families": { title: "Product Families", subtitle: "Manage product families" },
};
export function Header() {
const location = useLocation();
const page = PAGE_TITLES[location.pathname] || { title: "PIM Platform" };
return (
<header className="h-16 w-full flex items-center justify-between px-6 bg-surface border-b border-border sticky top-0 z-30">
<div>
<h1 className="text-xl font-semibold text-foreground">
{page.title}
</h1>
{page.subtitle && (
<p className="text-sm text-muted-foreground mt-0.5">
{page.subtitle}
</p>
)}
</div>
{/* Right Side Controls */}
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border hover:bg-surface-muted">
<Building2 className="w-4 h-4" />
<span>Acme Corp</span>
<ChevronDown className="w-3 h-3" />
</button>
<div className="relative">
<button className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted">
<Bell className="w-4 h-4" />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-primary text-white text-[10px] rounded-full flex items-center justify-center">2</span>
</button>
</div>
<button className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg hover:bg-surface-muted">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-primary to-primary-hover text-white text-xs font-bold flex items-center justify-center">
JD
</div>
<div className="text-left">
<div className="text-sm font-semibold">John Doe</div>
<div className="text-xs text-muted-foreground -mt-0.5">Admin</div>
</div>
</button>
</div>
</header>
);
}
+17 -31
View File
@@ -1,41 +1,27 @@
import React from 'react';
import { Sidebar } from '../customs/Sidebar';
import { Header } from '../customs/Headers';
// src/components/layouts/AppLayout.tsx
import { Outlet, useLocation } from 'react-router-dom';
import { Header } from './Headers';
import { Sidebar } from './Sidebar';
interface MainLayoutProps {
activeView: string;
onNavigate: (view: string) => void;
title: string;
subtitle?: string;
children: React.ReactNode;
}
const MainLayout = () => {
const location = useLocation();
const isSpecialPage = location.pathname.includes('/pos') || false; // Add special cases if needed
/**
* MainLayout
* Wraps every authenticated page with the fixed sidebar + sticky header.
* Usage: <MainLayout activeView={view} onNavigate={navigate} title="Products">
* <YourPage />
* </MainLayout>
*/
export function MainLayout({
activeView,
onNavigate,
title,
subtitle,
children,
}: MainLayoutProps) {
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Fixed left sidebar (240px) */}
<Sidebar activeView={activeView} onNavigate={onNavigate} />
{/* Sidebar */}
<Sidebar />
{/* Right column: sticky header + scrollable page content */}
{/* Main Content Area */}
<div className="flex flex-col flex-1 overflow-hidden">
<Header title={title} subtitle={subtitle} />
<main className="flex-1 overflow-y-auto bg-background">
{children}
<Header />
<main className="flex-1 overflow-y-auto bg-background p-6">
<Outlet />
</main>
</div>
</div>
);
}
};
export default MainLayout;
+19 -7
View File
@@ -1,3 +1,4 @@
// src/components/layouts/ProtectedRoute.tsx
import React from 'react';
import { usePermissions } from '../../hooks/usePermission';
import type { PermissionNodes } from '../../types/auth.types';
@@ -6,21 +7,32 @@ import { AccessDenied } from '../customs/AccessDenied';
interface ProtectedRouteProps {
node: PermissionNodes | string;
children: React.ReactNode;
fallback?: React.ReactNode; // Optional custom fallback
}
/**
* Page-level protection wrapper.
* Checks if the user has 'view' permission for the given node.
* If not, it renders the AccessDenied component instead of the page children.
* ProtectedRoute - Page Level Permission Guard
*
* Usage:
* <Route path="/products/*" element={
* <ProtectedRoute node="products.items">
* <ProductRoutes />
* </ProtectedRoute>
* } />
*/
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ node, children }) => {
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
node,
children,
fallback = <AccessDenied />
}) => {
const { canView } = usePermissions(node);
// If the user lacks view permission, show the Access Denied page/component.
if (!canView) {
return <AccessDenied />;
return <>{fallback}</>;
}
// Otherwise, render the page
return <>{children}</>;
};
// Also export as default for flexibility
export default ProtectedRoute;
+224
View File
@@ -0,0 +1,224 @@
// src/components/layouts/Sidebar.tsx
import React, { useState } from "react";
import { useSelector } from "react-redux";
import type { RootState } from "../../store";
import { hasPermission } from "../../utils/permissionUtils";
import { useLocation, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
Package,
FolderTree,
Grid3x3,
Tag,
Layers,
Upload,
Workflow,
Settings,
Database,
Ruler,
Award,
ChevronDown,
ChevronRight,
} from "lucide-react";
interface NavItem {
icon: React.ElementType;
label: string;
href: string;
badge?: string;
permissionNode?: string;
children?: NavItem[];
}
const navItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" },
// Products - Most Important
{
icon: Package,
label: "Products",
href: "/products",
badge: "12.4k",
permissionNode: "products.items"
},
{
icon: FolderTree,
label: "Product Families",
href: "/families",
permissionNode: "products.families"
},
{ icon: Grid3x3, label: "Variants", href: "/variants" },
{ icon: Tag, label: "Categories", href: "/categories" },
{
icon: Layers,
label: "Attribute Management",
href: "/attributes",
children: [
{ icon: Layers, label: "Attributes", href: "/attributes" },
{ icon: Grid3x3, label: "Attribute Groups", href: "/attribute-groups" }
]
},
{
icon: Database,
label: "Master Data",
href: "/master-data",
children: [
{ icon: Ruler, label: "Units", href: "/units" },
{ icon: Award, label: "Brands", href: "/brands" },
]
},
{ icon: Upload, label: "Supplier Imports", href: "/imports", badge: "3" },
{ icon: Workflow, label: "Workflow & Approvals", href: "/workflow", badge: "8" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const [expandedSections, setExpandedSections] = useState<string[]>([
"attribute-management", "master-data"
]);
const permissions = useSelector((state: RootState) => state.auth.permissions);
const toggleSection = (href: string) => {
setExpandedSections(prev =>
prev.includes(href) ? prev.filter(item => item !== href) : [...prev, href]
);
};
const isChildActive = (item: NavItem): boolean => {
if (!item.children) return false;
return item.children.some(child => location.pathname.startsWith(child.href));
};
const filterNavItems = (items: NavItem[]): NavItem[] => {
return items.reduce<NavItem[]>((acc, item) => {
if (item.permissionNode && !hasPermission(permissions, item.permissionNode, "view")) {
return acc;
}
if (item.children) {
const filteredChildren = filterNavItems(item.children);
if (filteredChildren.length > 0) {
acc.push({ ...item, children: filteredChildren });
}
return acc;
}
acc.push(item);
return acc;
}, []);
};
const filteredNavItems = filterNavItems(navItems);
return (
<div className="w-60 h-full bg-white border-r border-border flex flex-col">
{/* Logo Header */}
<div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center shadow-sm">
<Package className="w-4 h-4 text-white" />
</div>
<div>
<div className="font-semibold text-sm text-foreground">PIM Platform</div>
<div className="text-xs text-muted-foreground">Enterprise</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto py-3 px-3">
<nav className="space-y-0.5">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href ||
(item.children && isChildActive(item));
const hasChildren = !!item.children?.length;
const isExpanded = expandedSections.includes(item.href);
const hasActiveChild = isChildActive(item);
return (
<div key={item.href}>
<button
onClick={() => {
if (hasChildren) {
toggleSection(item.href);
} else {
navigate(item.href);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group text-sm font-medium ${
isActive || hasActiveChild
? "bg-primary text-white shadow-sm"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">{item.label}</span>
{item.badge && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
isActive ? "bg-white/20 text-white" : "bg-primary/10 text-primary"
}`}>
{item.badge}
</span>
)}
{hasChildren && (
isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />
)}
</button>
{/* Sub Items */}
{hasChildren && isExpanded && (
<div className="ml-4 mt-1 space-y-0.5">
{item.children!.map((child) => {
const ChildIcon = child.icon;
const isChildActiveItem = location.pathname === child.href;
return (
<button
key={child.href}
onClick={() => navigate(child.href)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all group text-sm ${
isChildActiveItem
? "bg-primary/10 text-primary font-medium"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<ChildIcon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-left">{child.label}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</nav>
</div>
{/* Footer */}
<div className="p-3 border-t border-border mt-auto">
<div className="p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm">
AC
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground truncate">Acme Corp</div>
<div className="text-xs text-muted-foreground truncate">Enterprise Plan</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { type ReactNode } from "react";
// ── Types ─────────────────────────────────────────────────────────────────────
export type InfoCardVariant =
| "default" // white, gray border (Dashboard style)
| "green" // light green tint (Active)
| "blue" // light blue tint (Draft)
| "amber" // light amber tint (Warning)
| "red" // light red tint (Disabled)
| "purple"; // light purple tint (Primary)
export interface InfoCardProps {
/** Small uppercase label at top-left */
label: string;
/** Large stat value */
value: string | number;
/** Icon element rendered at top-right */
icon: ReactNode;
/** Optional inline trend e.g. "+12.5%" — shown right next to value */
trend?: string;
/** Trend direction colours the text: "up"=green, "down"=red, "neutral"=gray */
trendDirection?: "up" | "down" | "neutral";
/** Subtitle / helper text below value */
subtitle?: string;
/** Colour variant */
variant?: InfoCardVariant;
className?: string;
}
// ── Style maps ────────────────────────────────────────────────────────────────
const CARD_STYLES: Record<InfoCardVariant, string> = {
default: "bg-white border border-gray-200",
green: "bg-green-50 border border-green-200",
blue: "bg-blue-50 border border-blue-200",
amber: "bg-amber-50 border border-amber-200",
red: "bg-red-50 border border-red-200",
purple: "bg-purple-50 border border-purple-200",
};
const TREND_STYLES: Record<string, string> = {
up: "text-green-600",
down: "text-red-500",
neutral: "text-gray-500",
};
// ── InfoCard ──────────────────────────────────────────────────────────────────
export function InfoCard({
label,
value,
icon,
trend,
trendDirection = "up",
subtitle,
variant = "default",
className = "",
}: InfoCardProps) {
return (
<div
className={`
relative rounded-xl p-5 flex flex-col gap-3
${CARD_STYLES[variant]} ${className}
`}
>
{/* Row 1: Label + Icon */}
<div className="flex items-start justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
{label}
</span>
<span className="text-gray-400">
{icon}
</span>
</div>
{/* Row 2: Value + inline trend */}
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 leading-none">
{value}
</span>
{trend && (
<span
className={`
text-xs font-semibold flex items-center gap-0.5
${TREND_STYLES[trendDirection]}
`}
>
{trendDirection === "up" && "↗"}
{trendDirection === "down" && "↘"}
{trend}
</span>
)}
</div>
{/* Row 3: Subtitle */}
{subtitle && (
<p className="text-xs text-gray-400 leading-tight">{subtitle}</p>
)}
</div>
);
}
// ── InfoCardGrid ──────────────────────────────────────────────────────────────
// Responsive grid wrapper. Default: 4 cols on lg, 2 on sm, 1 on xs.
interface InfoCardGridProps {
children: ReactNode;
cols?: 4 | 5 | 3 | 2;
className?: string;
}
const GRID_COLS: Record<number, string> = {
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
};
export function InfoCardGrid({ children, cols = 4, className = "" }: InfoCardGridProps) {
return (
<div className={`grid gap-4 ${GRID_COLS[cols]} ${className}`}>
{children}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "../../../lib/utils";
export type ChartConfig = {
[key: string]: {
label?: React.ReactNode;
color?: string;
};
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
export function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ReactNode;
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
className={cn("flex justify-center text-xs", className)}
{...props}
>
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartTooltip = RechartsPrimitive.Tooltip;
type TooltipPayloadItem = {
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
fill?: string;
payload?: any;
};
export function ChartTooltipContent({
active,
payload,
className,
}: React.ComponentProps<"div"> & {
active?: boolean;
payload?: TooltipPayloadItem[];
}) {
const { config } = useChart();
if (!active || !payload?.length) return null;
return (
<div
className={cn(
"border border-border/50 bg-white rounded-lg px-3 py-2 text-xs shadow-xl",
className
)}
>
{payload.map((item, index) => {
const itemConfig = config[item.name || item.dataKey || ""] || {};
const value = item.value;
return (
<div key={index} className="flex items-center gap-2 py-0.5">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color || item.fill }}
/>
<span className="text-muted-foreground">
{itemConfig.label || item.name}
</span>
<span className="font-medium ml-auto tabular-nums">
{typeof value === "number" ? value.toLocaleString() : value}
</span>
</div>
);
})}
</div>
);
}
// Only export what is needed (no duplicate exports)
export { ChartTooltip };
+26 -24
View File
@@ -5,18 +5,19 @@ import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
PieChart,
Pie,
Cell,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "../../../components/customs/chart";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
} from "../components/chart";
import { InfoCard, InfoCardGrid } from "../components/StatsCard";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
// ── Publication Trends data ───────────────────────────────────────────────────
@@ -35,13 +36,20 @@ const trendConfig: ChartConfig = {
// ── Channel Distribution data ─────────────────────────────────────────────────
const channelData = [
{ name: "E-commerce", value: 4200, color: "#7c3aed" },
{ name: "POS", value: 3100, color: "#10b981" },
{ name: "E-commerce", value: 4200, color: "#7c3aed" },
{ name: "POS", value: 3100, color: "#10b981" },
{ name: "Marketplaces", value: 2800, color: "#f59e0b" },
{ name: "Mobile App", value: 1900, color: "#06b6d4" },
{ name: "Mobile App", value: 1900, color: "#06b6d4" },
];
// ── Dashboard ─────────────────────────────────────────────────────────────────
const channelConfig: ChartConfig = {
"E-commerce": { label: "E-commerce", color: "#7c3aed" },
"POS": { label: "POS", color: "#10b981" },
"Marketplaces": { label: "Marketplaces", color: "#f59e0b" },
"Mobile App": { label: "Mobile App", color: "#06b6d4" },
};
// ── Dashboard Component ───────────────────────────────────────────────────────
export default function Dashboard() {
return (
<PageWrapper>
@@ -53,7 +61,7 @@ export default function Dashboard() {
</p>
</div>
{/* ── KPI InfoCards ── */}
{/* KPI InfoCards */}
<InfoCardGrid cols={4} className="mb-6">
<InfoCard
label="Total Products"
@@ -87,10 +95,10 @@ export default function Dashboard() {
/>
</InfoCardGrid>
{/* ── Charts row ── */}
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Publication Trends Line chart */}
{/* Publication Trends - Line Chart */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900">Publication Trends</h3>
@@ -125,13 +133,13 @@ export default function Dashboard() {
</ChartContainer>
</div>
{/* Channel Distribution — Donut chart */}
{/* Channel Distribution - Pie Chart */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Channel Distribution</h3>
<div className="flex items-center gap-6">
{/* Donut */}
{/* Donut Chart */}
<div className="flex-shrink-0">
<ChartContainer config={{}} className="h-52 w-52">
<ChartContainer config={channelConfig} className="h-52 w-52">
<PieChart>
<Pie
data={channelData}
@@ -146,14 +154,7 @@ export default function Dashboard() {
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), "Products"]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #e5e7eb",
fontSize: "12px",
}}
/>
<ChartTooltip content={<ChartTooltipContent />} />
</PieChart>
</ChartContainer>
</div>
@@ -168,15 +169,16 @@ export default function Dashboard() {
/>
<div>
<div className="text-sm font-medium text-gray-800">{item.name}</div>
<div className="text-xs text-gray-400">{item.value.toLocaleString()} products</div>
<div className="text-xs text-gray-400">
{item.value.toLocaleString()} products
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</PageWrapper>
);
}
}
@@ -0,0 +1,13 @@
// src/features/dashboard/routes/dashboard.routes.tsx
import { Routes, Route } from 'react-router-dom';
import Dashboard from '../pages/Dashboard';
export const DashboardRoutes = () => {
return (
<Routes>
<Route index element={<Dashboard />} />
</Routes>
);
};
export default DashboardRoutes;
+25
View File
@@ -0,0 +1,25 @@
import { PageWrapper } from "../../../components/layouts/PageWrapper";
const FamilyList = () => {
return (
<PageWrapper>
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">
Product Families
</h1>
<p className="text-sm text-muted-foreground">
Manage product family definitions and variant axes.
</p>
</div>
<div className="rounded-xl border border-border bg-surface p-6">
Family table goes here
</div>
</div>
</PageWrapper>
);
};
export default FamilyList;
+25
View File
@@ -0,0 +1,25 @@
import { PageWrapper } from "../../../components/layouts/PageWrapper";
const NewFamily = () => {
return (
<PageWrapper>
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground">
Create Product Family
</h1>
<p className="text-sm text-muted-foreground">
Create a new family and define variant axes.
</p>
</div>
<div className="rounded-xl border border-border bg-surface p-6">
Family form goes here
</div>
</div>
</PageWrapper>
);
};
export default NewFamily;
@@ -0,0 +1,16 @@
// src/features/family/routes/family.routes.tsx
import { Routes, Route } from 'react-router-dom';
import FamilyList from '../pages/FamilyList';
import NewFamily from '../pages/NewFamily';
export const FamilyRoutes = () => {
return (
<Routes>
<Route path="/" element={<FamilyList />} />
<Route path="new" element={<NewFamily />} />
<Route path=":id/edit" element={<NewFamily />} />
</Routes>
);
};
export default FamilyRoutes;
+15
View File
@@ -0,0 +1,15 @@
import { PageWrapper } from "../../../components/layouts/PageWrapper";
const NewProduct = () => {
return (
<PageWrapper>
<div className="space-y-6">
<h1 className="text-xl font-semibold">
Create Product
</h1>
</div>
</PageWrapper>
);
};
export default NewProduct;
+136 -79
View File
@@ -1,16 +1,16 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { CheckCircle2, Clock, Box, AlertCircle, Plus } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
import { Button, ExportButton, ImportButton } from "../../../components/customs/Button";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Checkbox } from "../../../components/customs/CheckBox";
// ── Product icon (small purple hexagon) ────────────────────────────────────────
// ── Product icon ────────────────────────────────────────
function ProductIcon() {
return (
<div className="w-9 h-9 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
@@ -19,7 +19,7 @@ function ProductIcon() {
);
}
// ── Mock data ──────────────────────────────────────────────────────────────────
// ── Mock Data ───────────────────────────────────────────
type ProductStatus = "active" | "pending" | "draft" | "disabled";
interface Product {
@@ -39,43 +39,99 @@ interface Product {
}
const MOCK_PRODUCTS: Product[] = [
{ id: "1", name: "Premium Wireless Headphones", category: "Consumer Electronics", subcategory: "Audio Devices", sku: "WH-PWH-001", productId: "PRD-001", status: "active", completeness: 95, stock: 1245, price: "$299.99", channels: { active: 4, total: 6 }, updatedAt: "2 hours ago", updatedBy: "Sarah J." },
{ id: "2", name: "Smart Home Bundle", category: "Smart Home", subcategory: "Bundles", sku: "SH-SHB-002", productId: "PRD-002", status: "pending", completeness: 72, stock: 89, price: "$549.99", channels: { active: 0, total: 6 }, updatedAt: "1 day ago", updatedBy: "Mike C." },
{ id: "3", name: "Eco-Friendly Water Bottle", category: "Health & Wellness", subcategory: "Bottles", sku: "HW-EWB-003", productId: "PRD-003", status: "active", completeness: 100, stock: 2456, price: "$24.99", channels: { active: 6, total: 6 }, updatedAt: "3 days ago", updatedBy: "Jane W." },
{ id: "4", name: "Ergonomic Office Chair", category: "Furniture", subcategory: "Seating", sku: "FN-EOC-004", productId: "PRD-004", status: "draft", completeness: 45, stock: 0, price: "$449.00", channels: { active: 0, total: 6 }, updatedAt: "5 days ago", updatedBy: "Tom R." },
{ id: "5", name: "Portable Bluetooth Speaker", category: "Consumer Electronics", subcategory: "Audio Devices", sku: "CE-PBS-005", productId: "PRD-005", status: "active", completeness: 88, stock: 670, price: "$79.99", channels: { active: 3, total: 6 }, updatedAt: "1 week ago", updatedBy: "Sarah J." },
{ id: "6", name: "Organic Green Tea Set", category: "Food & Beverage", subcategory: "Tea", sku: "FB-OGT-006", productId: "PRD-006", status: "active", completeness: 91, stock: 1800, price: "$34.50", channels: { active: 5, total: 6 }, updatedAt: "1 week ago", updatedBy: "Lisa M." },
{ id: "7", name: "Running Shoes Ultra", category: "Sports", subcategory: "Footwear", sku: "SP-RSU-007", productId: "PRD-007", status: "draft", completeness: 38, stock: 0, price: "$129.00", channels: { active: 0, total: 6 }, updatedAt: "2 weeks ago", updatedBy: "Tom R." },
{ id: "8", name: "LED Desk Lamp Pro", category: "Home", subcategory: "Lighting", sku: "HM-LDL-008", productId: "PRD-008", status: "active", completeness: 82, stock: 430, price: "$59.99", channels: { active: 2, total: 6 }, updatedAt: "2 weeks ago", updatedBy: "Mike C." },
{
id: "1",
name: "Wireless Headphones Pro",
category: "Electronics",
subcategory: "Audio",
sku: "WH-PRO-001",
productId: "P1001",
status: "active",
completeness: 95,
stock: 1240,
price: "$89.99",
channels: { active: 4, total: 5 },
updatedAt: "2 hours ago",
updatedBy: "Sarah Chen",
},
{
id: "2",
name: "Organic Cotton T-Shirt",
category: "Fashion",
subcategory: "Apparel",
sku: "TS-ORG-045",
productId: "P1002",
status: "pending",
completeness: 65,
stock: 450,
price: "$24.99",
channels: { active: 2, total: 4 },
updatedAt: "Yesterday",
updatedBy: "Mike Torres",
},
{
id: "3",
name: "Stainless Steel Water Bottle",
category: "Lifestyle",
subcategory: "Drinkware",
sku: "WB-SS-078",
productId: "P1003",
status: "active",
completeness: 88,
stock: 3200,
price: "$19.99",
channels: { active: 5, total: 5 },
updatedAt: "3 days ago",
updatedBy: "Anna Kim",
},
{
id: "4",
name: "Smart LED Desk Lamp",
category: "Home",
subcategory: "Lighting",
sku: "LAMP-SM-112",
productId: "P1004",
status: "draft",
completeness: 45,
stock: 0,
price: "$49.99",
channels: { active: 0, total: 3 },
updatedAt: "1 week ago",
updatedBy: "David Park",
},
];
const STATUS_MAP: Record<ProductStatus, "active" | "pending" | "draft" | "disabled"> = {
active: "active",
pending: "pending",
draft: "draft",
// ── Status Helpers ──────────────────────────────────────
const STATUS_MAP: Record<ProductStatus, ProductStatus> = {
active: "active",
pending: "pending",
draft: "draft",
disabled: "disabled",
};
const STATUS_LABEL: Record<ProductStatus, string> = {
active: "Published",
pending: "Pending Review",
draft: "Draft",
active: "Published",
pending: "Pending Review",
draft: "Draft",
disabled: "Disabled",
};
// ── Completeness bar color ─────────────────────────────────────────────────────
function completenessColor(pct: number) {
if (pct >= 80) return "bg-green-500";
if (pct >= 60) return "bg-amber-400";
return "bg-red-400";
}
// ── Component ──────────────────────────────────────────────────────────────────
// ── Main Component ──────────────────────────────────────
export default function ProductList() {
const { canImport, canExport } = usePermissions("products.items");
const { query, setQuery, viewMode, setViewMode } = useSearch();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
console.log("Products page loaded");
}, []);
const q = query.toLowerCase();
const filtered = query.trim()
? MOCK_PRODUCTS.filter((p) =>
@@ -86,45 +142,43 @@ export default function ProductList() {
)
: MOCK_PRODUCTS;
// Selection helpers
const allSelected = filtered.length > 0 && selected.size === filtered.length;
const someSelected = selected.size > 0 && !allSelected;
function toggleAll() {
const toggleAll = () => {
if (allSelected) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((p) => p.id)));
}
}
};
function toggleOne(id: string) {
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
};
// Counts
const published = MOCK_PRODUCTS.filter((p) => p.status === "active").length;
const published = MOCK_PRODUCTS.filter((p) => p.status === "active").length;
const pendingReview = MOCK_PRODUCTS.filter((p) => p.status === "pending").length;
const draft = MOCK_PRODUCTS.filter((p) => p.status === "draft").length;
const incomplete = MOCK_PRODUCTS.filter((p) => p.completeness < 80).length;
const draft = MOCK_PRODUCTS.filter((p) => p.status === "draft").length;
const incomplete = MOCK_PRODUCTS.filter((p) => p.completeness < 80).length;
return (
<ProtectedRoute node="products.items">
<PageWrapper>
{/* ── Breadcrumb ─────────────────────────── */}
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-gray-400 mb-4">
<span className="hover:text-gray-600 cursor-pointer transition-colors">Dashboard</span>
<span></span>
<span className="text-gray-700 font-medium">Products</span>
</nav>
{/* ── Page heading + actions ──────────────────── */}
<div className="flex items-start justify-between mb-5">
{/* Header + Actions */}
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-900">Products</h2>
<p className="text-sm text-gray-500 mt-0.5">
@@ -142,15 +196,39 @@ export default function ProductList() {
</div>
</div>
{/* ── Status InfoCards ────────────────────────── */}
<InfoCardGrid cols={4} className="mb-5">
<InfoCard label="Published" value={published} icon={<CheckCircle2 className="w-5 h-5 text-green-500" />} variant="green" />
<InfoCard label="Pending Review" value={pendingReview} icon={<Clock className="w-5 h-5 text-amber-500" />} variant="amber" />
<InfoCard label="Draft" value={draft} icon={<Box className="w-5 h-5 text-gray-400" />} variant="default" />
<InfoCard label="Incomplete (<80%)" value={incomplete} icon={<AlertCircle className="w-5 h-5 text-red-500" />} variant="red" />
</InfoCardGrid>
{/* KPI Section - Updated */}
<KPIGrid className="mb-6">
<KPI
label="Published"
value={published}
icon={CheckCircle2}
variant="green"
trendValue="+12%"
trendDirection="up"
/>
<KPI
label="Pending Review"
value={pendingReview}
icon={Clock}
variant="amber"
/>
<KPI
label="Draft"
value={draft}
icon={Box}
variant="slate"
/>
<KPI
label="Incomplete"
value={incomplete}
icon={AlertCircle}
variant="red"
trendValue="-5%"
trendDirection="down"
/>
</KPIGrid>
{/* ── SearchBar ──────────────────────────────── */}
{/* Search & Table */}
<SearchBar
value={query}
onChange={setQuery}
@@ -162,16 +240,16 @@ export default function ProductList() {
className="mb-5"
/>
{/* ── Bulk action bar ────────────────────────── */}
{/* Bulk Actions */}
{selected.size > 0 && (
<div className="flex items-center justify-between bg-purple-50 border border-purple-200 rounded-xl px-4 py-2.5 mb-4 animate-in fade-in">
<div className="flex items-center justify-between bg-purple-50 border border-purple-200 rounded-xl px-4 py-2.5 mb-4">
<span className="text-sm font-medium text-primary">
{selected.size} product{selected.size > 1 ? "s" : ""} selected
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">Publish Selected</Button>
<Button variant="outline" size="sm">Export Selected</Button>
<Button variant="danger" size="sm">Delete Selected</Button>
<Button variant="danger" size="sm">Delete Selected</Button>
<button
onClick={() => setSelected(new Set())}
className="text-sm text-primary font-medium hover:underline ml-1"
@@ -182,13 +260,7 @@ export default function ProductList() {
</div>
)}
{/* ── Result count ───────────────────────────── */}
<p className="text-xs text-gray-400 mb-3">
Showing <span className="font-medium text-gray-600">{filtered.length}</span> of{" "}
<span className="font-medium text-gray-600">{MOCK_PRODUCTS.length}</span> products
</p>
{/* ── Data table ─────────────────────────────── */}
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-gray-50/80 border-b border-gray-200">
@@ -220,41 +292,34 @@ export default function ProductList() {
filtered.map((p) => (
<tr
key={p.id}
className={`border-b border-gray-100 hover:bg-gray-50/60 transition-colors ${selected.has(p.id) ? "bg-purple-50/40" : ""}`}
className={`border-b border-gray-100 hover:bg-gray-50/60 transition-colors ${
selected.has(p.id) ? "bg-purple-50/40" : ""
}`}
>
{/* Checkbox */}
<td className="w-10 px-4 py-4">
<Checkbox
checked={selected.has(p.id)}
onCheckedChange={() => toggleOne(p.id)}
/>
</td>
{/* Product (icon + name + category) */}
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<ProductIcon />
<div>
<div className="text-sm font-medium text-gray-900 leading-tight">{p.name}</div>
<div className="text-xs text-gray-400 mt-0.5">
<div className="text-sm font-medium text-gray-900">{p.name}</div>
<div className="text-xs text-gray-400">
{p.category} · {p.subcategory}
</div>
</div>
</div>
</td>
{/* SKU / ID */}
<td className="px-4 py-4">
<div className="text-sm font-mono text-gray-600">{p.sku}</div>
<div className="text-xs text-gray-400">{p.productId}</div>
</td>
{/* Status */}
<td className="px-4 py-4">
<StatusBadge status={STATUS_MAP[p.status]} label={STATUS_LABEL[p.status]} />
</td>
{/* Completeness */}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
@@ -266,25 +331,17 @@ export default function ProductList() {
<span className="text-xs text-gray-500">{p.completeness}%</span>
</div>
</td>
{/* Stock */}
<td className="px-4 py-4 text-sm text-gray-600 tabular-nums">
{p.stock.toLocaleString()}
</td>
{/* Price */}
<td className="px-4 py-4 text-sm font-medium text-gray-800 tabular-nums">
{p.price}
</td>
{/* Channels */}
<td className="px-4 py-4 text-sm text-gray-600 tabular-nums">{p.stock.toLocaleString()}</td>
<td className="px-4 py-4 text-sm font-medium text-gray-800 tabular-nums">{p.price}</td>
<td className="px-4 py-4">
<span className={`text-sm tabular-nums ${p.channels.active === 0 ? "text-gray-400" : "text-gray-600"}`}>
<span
className={`text-sm tabular-nums ${
p.channels.active === 0 ? "text-gray-400" : "text-gray-600"
}`}
>
{p.channels.active}/{p.channels.total}
</span>
</td>
{/* Updated */}
<td className="px-4 py-4">
<div className="text-xs text-gray-500">{p.updatedAt}</div>
<div className="text-xs text-gray-400">by {p.updatedBy}</div>
@@ -298,4 +355,4 @@ export default function ProductList() {
</PageWrapper>
</ProtectedRoute>
);
}
}
@@ -0,0 +1,16 @@
// src/features/product/routes/product.routes.tsx
import { Routes, Route } from 'react-router-dom';
import ProductList from '../pages/ProductList';
import NewProduct from '../pages/NewProduct';
export const ProductRoutes = () => {
return (
<Routes>
<Route index element={<ProductList />} /> {/* /products */}
<Route path="new" element={<NewProduct />} /> {/* /products/new */}
<Route path=":id/edit" element={<NewProduct />} /> {/* /products/123/edit */}
</Routes>
);
};
export default ProductRoutes;
+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import { ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react";
interface Column {
key: string;
label: string;
sortable?: boolean;
width?: string;
}
interface DataTableProps {
columns: Column[];
data: any[];
onRowClick?: (row: any) => void;
selectable?: boolean;
actions?: (row: any) => ReactNode;
}
export function DataTable({
columns,
data,
onRowClick,
selectable = false,
actions
}: DataTableProps) {
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{selectable && (
<th className="w-12 px-4 py-3 text-left">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
</th>
)}
{columns.map((column) => (
<th
key={column.key}
className={`px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide ${column.width || ''}`}
>
<div className="flex items-center gap-2">
{column.label}
{column.sortable && (
<div className="flex flex-col">
<ChevronUp className="w-3 h-3 -mb-1 text-gray-400 hover:text-gray-600 cursor-pointer" />
<ChevronDown className="w-3 h-3 text-gray-400 hover:text-gray-600 cursor-pointer" />
</div>
)}
</div>
</th>
))}
{actions && (
<th className="w-12 px-4 py-3 text-right">
<span className="sr-only">Actions</span>
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{data.map((row, index) => (
<tr
key={index}
onClick={() => onRowClick?.(row)}
className={`hover:bg-gray-50 transition-colors ${onRowClick ? 'cursor-pointer' : ''}`}
>
{selectable && (
<td className="px-4 py-3">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((column) => (
<td key={column.key} className="px-4 py-3.5 text-sm text-gray-900">
{row[column.key]}
</td>
))}
{actions && (
<td className="px-4 py-3 text-right">
<button
onClick={(e) => {
e.stopPropagation();
}}
className="p-1 rounded hover:bg-gray-100"
>
<MoreHorizontal className="w-4 h-4 text-gray-400" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import { Plus, Download, Upload, Edit, Copy, Trash2 } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
import { Button, ExportButton, ImportButton } from "../../../components/customs/Button";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../components/DataTable";
import { Radio, Box ,AlertCircle, CheckCircle2 } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────
type UnitStatus = "active" | "inactive";
interface Unit {
id: string;
name: string;
code: string;
symbol: string;
unitType: string;
conversionFactor?: number;
baseUnit?: string;
status: UnitStatus;
description?: string;
lastUpdated: string;
createdBy: string;
}
// ── Mock Data ─────────────────────────────────────────────────────────
const MOCK_UNITS: Unit[] = [
{ id: "1", name: "Kilogram", code: "kg", symbol: "kg", unitType: "Weight", conversionFactor: 1, baseUnit: "kg", status: "active", description: "Base unit for weight", lastUpdated: "2 hours ago", createdBy: "Admin" },
{ id: "2", name: "Gram", code: "g", symbol: "g", unitType: "Weight", conversionFactor: 0.001, baseUnit: "kg", status: "active", lastUpdated: "Yesterday", createdBy: "Admin" },
{ id: "3", name: "Pound", code: "lb", symbol: "lb", unitType: "Weight", conversionFactor: 0.453592, baseUnit: "kg", status: "active", lastUpdated: "3 days ago", createdBy: "Admin" },
{ id: "4", name: "Meter", code: "m", symbol: "m", unitType: "Length", conversionFactor: 1, baseUnit: "m", status: "active", lastUpdated: "1 week ago", createdBy: "Admin" },
{ id: "5", name: "Liter", code: "l", symbol: "L", unitType: "Volume", conversionFactor: 1, baseUnit: "l", status: "inactive", lastUpdated: "2 weeks ago", createdBy: "Admin" },
];
export default function UnitList() {
const { canImport, canExport } = usePermissions("masters.units");
const { query, setQuery } = useSearch();
const [units] = useState<Unit[]>(MOCK_UNITS);
const filteredUnits = units.filter((unit) =>
unit.name.toLowerCase().includes(query.toLowerCase()) ||
unit.code.toLowerCase().includes(query.toLowerCase()) ||
unit.symbol.toLowerCase().includes(query.toLowerCase()) ||
unit.unitType.toLowerCase().includes(query.toLowerCase())
);
const activeUnits = units.filter(u => u.status === "active").length;
const inactiveUnits = units.filter(u => u.status === "inactive").length;
const handleRowClick = (unit: Unit) => {
console.log("View unit details:", unit);
};
const columns = [
{ key: "name", label: "Unit Name" },
{ key: "code", label: "Code" },
{ key: "symbol", label: "Symbol" },
{ key: "unitType", label: "Type" },
{
key: "conversionFactor",
label: "Conversion",
render: (value: any, row: Unit) => value !== undefined ? `${value} ${row.baseUnit || ''}` : "—"
},
{
key: "status",
label: "Status",
render: (value: UnitStatus) => (
<StatusBadge
status={value === "active" ? "active" : "disabled"}
label={value === "active" ? "Active" : "Inactive"}
/>
)
},
{ key: "lastUpdated", label: "Last Updated" },
];
return (
<ProtectedRoute node="masters.units">
<PageWrapper>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Units</h1>
<p className="text-sm text-gray-500 mt-1">
Manage measurement units for your product catalog
</p>
</div>
<div className="flex items-center gap-3 mt-4 sm:mt-0">
{canExport && <ExportButton />}
{canImport && <ImportButton />}
<Can node="masters.units" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
Create Unit
</Button>
</Can>
</div>
</div>
{/* KPI Stats */}
<KPIGrid className="mb-8">
<KPI label="Total Units" value={units.length} icon={Box} variant="purple" />
<KPI label="Active Units" value={activeUnits} icon={CheckCircle2} variant="green" />
<KPI label="Inactive Units" value={inactiveUnits} icon={AlertCircle} variant="slate" />
<KPI label="Unit Types" value={new Set(units.map(u => u.unitType)).size} icon={Radio} variant="blue" />
</KPIGrid>
{/* Search */}
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search by name, code, symbol or type..."
className="mb-6"
/>
{/* DataTable */}
<DataTable
columns={columns}
data={filteredUnits}
onRowClick={handleRowClick}
actions={(row: Unit) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<Edit className="w-4 h-4" />}
onClick={() => console.log("Edit unit:", row)}
>
{/* Empty children to satisfy TypeScript */}
</Button>
<Button
variant="ghost"
size="sm"
icon={<Copy className="w-4 h-4" />}
onClick={() => console.log("Duplicate unit:", row)}
>
</Button>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={() => {
if (confirm(`Delete ${row.name}?`)) {
console.log("Delete unit:", row);
}
}}
>
</Button>
</div>
)}
/>
{/* Footer Info */}
{filteredUnits.length > 0 && (
<div className="text-xs text-gray-500 mt-4 px-1">
Showing <strong>{filteredUnits.length}</strong> of <strong>{units.length}</strong> units
</div>
)}
</PageWrapper>
</ProtectedRoute>
);
}
+14 -10
View File
@@ -1,14 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById('root')!).render(
import { store } from "./store";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)
</StrictMode>
);
+36
View File
@@ -0,0 +1,36 @@
// src/routes/AppRoutes.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthGuard } from '../authentication/components/ProtectedRoute';
import MainLayout from '../components/layouts/MainLayout';
// Feature Routes
import { DashboardRoutes } from '../features/dashboard/routes/dashboard.routes';
import { ProductRoutes } from '../features/product/routes/product.routes';
import { FamilyRoutes } from '../features/family/routes/family.routes';
const AppRoutes = () => {
return (
<Routes>
{/* Default Redirect */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Protected Routes with Layout */}
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
<Route path="/dashboard" element={<DashboardRoutes />} />
{/* Products */}
<Route path="/products/*" element={<ProductRoutes />} />
{/* Families */}
<Route path="/families/*" element={<FamilyRoutes />} />
{/* Add more features here later */}
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
};
export default AppRoutes;
+24
View File
@@ -0,0 +1,24 @@
// src/routes/routeConfig.ts
export interface RouteConfig {
path: string;
module?: string;
action?: string;
title: string;
description?: string;
}
export const protectedRoutes: RouteConfig[] = [
{ path: '/dashboard', title: 'Dashboard', description: 'Overview of your PIM platform' },
{ path: '/products', module: 'products', action: 'read', title: 'Products', description: 'Manage product catalog' },
{ path: '/families', module: 'families', action: 'read', title: 'Product Families', description: 'Manage product families' },
{ path: '/variants', module: 'products', action: 'read', title: 'Variants' },
{ path: '/categories', module: 'products', action: 'read', title: 'Categories' },
{ path: '/attributes', module: 'products', action: 'read', title: 'Attributes' },
{ path: '/brands', module: 'masters', action: 'read', title: 'Brands' },
{ path: '/units', module: 'masters', action: 'read', title: 'Units' },
];
export const publicRoutes: RouteConfig[] = [
{ path: '/', title: 'Home' },
{ path: '/login', title: 'Login' },
];
+81 -35
View File
@@ -1,39 +1,85 @@
import type { PermissionNodes } from '../types/auth.types';
// src/routes/sidebar.config.ts
import {
LayoutDashboard,
Package,
FolderTree,
Grid3x3,
Tag,
Layers,
Upload,
Workflow,
Settings,
Database,
Ruler,
Award,
} from 'lucide-react';
import React from 'react';
export interface SidebarMenuItem {
title: string;
viewKey: string;
icon?: string;
// Attach the permission node required to view this menu item
permissionNode: PermissionNodes | string;
export interface SidebarSubItem {
label: string;
href: string;
icon: React.ElementType;
permission: string;
}
export const SIDEBAR_NAV_CONFIG: SidebarMenuItem[] = [
{
title: 'Products',
viewKey: 'products',
permissionNode: 'products.items',
},
{
title: 'Category Trees',
viewKey: 'category-trees',
permissionNode: 'products.categories',
},
{
title: 'Inventory Stock',
viewKey: 'inventory-stock',
permissionNode: 'inventory.stock',
},
// Add other menu items following the same pattern
];
export interface SidebarItem {
label: string;
href: string;
icon: React.ElementType;
permission?: string;
children?: SidebarSubItem[];
}
/**
* Example usage in Sidebar component:
*
* const { permissions } = useSelector((state: RootState) => state.auth);
*
* const visibleMenuItems = SIDEBAR_NAV_CONFIG.filter(item => {
* // Only display menu items when permission.view === true
* return hasPermission(permissions, item.permissionNode, 'view');
* });
*/
export const sidebarConfig: SidebarItem[] = [
{
label: 'Dashboard',
href: '/dashboard',
icon: LayoutDashboard
},
{
label: 'Catalog',
href: '/products',
icon: Package,
permission: 'products',
children: [
{ label: 'Products', href: '/products', icon: Package, permission: 'products.items' },
{ label: 'Product Families', href: '/families', icon: FolderTree, permission: 'products.families' },
{ label: 'Categories', href: '/categories', icon: FolderTree, permission: 'products.categories' },
{ label: 'Variants', href: '/variants', icon: Grid3x3, permission: 'products.variants' },
{ label: 'Attributes', href: '/attributes', icon: Tag, permission: 'products.attributes' },
]
},
{
label: 'Masters',
href: '/master-data',
icon: Database,
permission: 'masters',
children: [
{ label: 'Brands', href: '/brands', icon: Award, permission: 'masters.brands' },
{ label: 'Units', href: '/units', icon: Ruler, permission: 'masters.units' },
]
},
{
label: 'Supplier Imports',
href: '/imports',
icon: Upload,
permission: 'imports'
},
{
label: 'Workflow & Approvals',
href: '/workflow',
icon: Workflow,
permission: 'workflow'
},
{
label: 'Settings',
href: '/settings',
icon: Settings,
permission: 'settings'
},
];
BIN
View File
Binary file not shown.
+11
View File
@@ -1,9 +1,20 @@
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+3 -2
View File
@@ -1,9 +1,10 @@
// src/store/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';
const rootReducer = combineReducers({
auth: authReducer,
// ... other reducers will be added here
// Add more reducers here later (e.g., products, ui, etc.)
});
export default rootReducer;
export default rootReducer;
+42 -27
View File
@@ -1,36 +1,47 @@
// src/store/slices/authSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { PermissionPayload } from '../../types/auth.types';
// Mock permissions for testing the implementation
const mockPermissions: PermissionPayload = {
"products.items": {
view: true,
create: true,
edit: true,
delete: false,
alter: false,
import: true,
export: true
},
"products.categories": {
view: true,
create: false,
edit: false,
delete: false,
alter: false,
import: false,
export: false
}
};
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
user: any | null;
permissions: PermissionPayload;
}
const initialState: AuthState = {
isAuthenticated: true, // Mocked as true for testing RBAC
permissions: mockPermissions,
isAuthenticated: true, // true = development mode (no login required)
isLoading: false,
user: null,
permissions: {
"products.items": {
view: true,
create: true,
edit: true,
delete: true,
alter: true,
import: true,
export: true,
},
"products.families": {
view: true,
create: true,
edit: true,
delete: true,
alter: false,
import: true,
export: true,
},
"products.categories": {
view: true,
create: true,
edit: true,
delete: false,
alter: false,
import: false,
export: true,
},
},
};
const authSlice = createSlice({
@@ -40,12 +51,16 @@ const authSlice = createSlice({
setPermissions: (state, action: PayloadAction<PermissionPayload>) => {
state.permissions = action.payload;
},
setAuthLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
logout: (state) => {
state.isAuthenticated = false;
state.user = null;
state.permissions = {};
}
},
},
});
export const { setPermissions, logout } = authSlice.actions;
export default authSlice.reducer;
export const { setPermissions, setAuthLoading, logout } = authSlice.actions;
export default authSlice.reducer;
+1 -1
View File
@@ -21,4 +21,4 @@ export const hasPermission = (
if (!nodePermissions) return false;
return nodePermissions[action] === true;
};
};