some updation
This commit is contained in:
BIN
Binary file not shown.
Generated
+711
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+37
-111
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
// },
|
||||
// };
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -0,0 +1,10 @@
|
||||
// Re-export all auth types
|
||||
export type {
|
||||
Store,
|
||||
User,
|
||||
PermissionActions,
|
||||
Permissions,
|
||||
AuthState,
|
||||
LoginResponse,
|
||||
LoginRequest,
|
||||
} from './auth.types';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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'
|
||||
},
|
||||
];
|
||||
Binary file not shown.
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -21,4 +21,4 @@ export const hasPermission = (
|
||||
if (!nodePermissions) return false;
|
||||
|
||||
return nodePermissions[action] === true;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user