create custom modules

This commit is contained in:
MohamedHasan07
2026-06-16 18:46:54 +05:30
parent f24968be49
commit 8ef8c6b81c
22 changed files with 3028 additions and 216 deletions
+545 -1
View File
@@ -8,11 +8,16 @@
"name": "pim",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.5",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.1",
"clsx": "^2.1.1",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-redux": "^9.3.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
@@ -573,6 +578,221 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"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",
"integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==",
"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-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "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-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"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-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"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-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
"license": "MIT",
"dependencies": {
"@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-primitive": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
"license": "MIT",
"dependencies": {
"@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-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"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",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@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-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"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-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",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"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-previous": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz",
"integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==",
"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-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"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/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@@ -1232,6 +1452,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1277,7 +1560,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -1699,6 +1982,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1728,6 +2020,127 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1746,6 +2159,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1782,6 +2201,16 @@
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
"version": "1.47.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1990,6 +2419,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2182,6 +2617,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2574,6 +3018,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
"integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2798,6 +3251,13 @@
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
@@ -2821,6 +3281,52 @@
}
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/recharts/node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -2925,6 +3431,16 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
@@ -2944,6 +3460,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3088,6 +3610,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
+5
View File
@@ -10,11 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.5",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.1",
"clsx": "^2.1.1",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-redux": "^9.3.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
+112 -114
View File
@@ -1,121 +1,119 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
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';
function App() {
const [count, setCount] = useState(0)
// ── 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';
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
// ── 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>
);
<div className="ticks"></div>
// ── 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' },
};
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
// ── View → Component mapping ─────────────────────────────────────────────────
function renderView(view: string) {
switch (view) {
// ── Real pages ──────────────────────────────────────────────────────────
case 'dashboard':
return <Dashboard />;
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
// ── 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 />;
}
}
export default App
// ── App Root ──────────────────────────────────────────────────────────────────
function App() {
const [activeView, setActiveView] = useState('dashboard');
const { title, subtitle } = VIEW_META[activeView] ?? { title: 'PIM Platform' };
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>
);
}
export default App;
+191
View File
@@ -0,0 +1,191 @@
import { type ReactNode } from "react";
// ── Base Button ───────────────────────────────────────────────────────────────
interface ButtonProps {
children: ReactNode;
variant?: "primary" | "secondary" | "outline" | "ghost" | "danger" | "success";
size?: "sm" | "md" | "lg";
disabled?: boolean;
loading?: boolean;
icon?: ReactNode;
onClick?: () => void;
className?: string;
type?: "button" | "submit" | "reset";
}
export function Button({
children,
variant = "primary",
size = "md",
disabled = false,
loading = false,
icon,
onClick,
className = "",
type = "button",
}: ButtonProps) {
const baseStyles =
"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",
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",
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
lg: "h-10 px-5 text-sm",
};
const disabledStyles =
disabled || loading ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
return (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${disabledStyles} ${className}`}
>
{loading && (
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{icon && !loading && icon}
{children}
</button>
);
}
// ── ExportButton ──────────────────────────────────────────────────────────────
// Pre-configured outline button with Excel/CSV export icon.
interface ActionButtonProps {
onClick?: () => void;
loading?: boolean;
disabled?: boolean;
label?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
export function ExportButton({
onClick,
loading = false,
disabled = false,
label = "Export",
size = "md",
className = "",
}: ActionButtonProps) {
return (
<Button
variant="outline"
size={size}
onClick={onClick}
loading={loading}
disabled={disabled}
className={className}
icon={
// Download / export arrow icon
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
}
>
{label}
</Button>
);
}
// ── ImportButton ──────────────────────────────────────────────────────────────
// Pre-configured outline button with Excel/CSV import icon.
export function ImportButton({
onClick,
loading = false,
disabled = false,
label = "Import",
size = "md",
className = "",
}: ActionButtonProps) {
return (
<Button
variant="outline"
size={size}
onClick={onClick}
loading={loading}
disabled={disabled}
className={className}
icon={
// Upload / import arrow icon
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
{label}
</Button>
);
}
// ── IconButton ────────────────────────────────────────────────────────────────
// Square icon-only button (for table row actions, toggles, etc.)
interface IconButtonProps {
icon: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
variant?: "ghost" | "outline" | "danger-ghost";
size?: "sm" | "md";
active?: boolean;
className?: string;
}
export function IconButton({
icon,
onClick,
disabled = false,
title,
variant = "ghost",
size = "md",
active = false,
className = "",
}: IconButtonProps) {
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",
};
const sizes = {
sm: "w-7 h-7",
md: "w-8 h-8",
};
const activeClass = active
? "bg-primary/10 text-primary border-primary/20"
: "";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`${base} ${variants[variant]} ${sizes[size]} ${activeClass} ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"} ${className}`}
>
{icon}
</button>
);
}
View File
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "../../lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border border-gray-300 bg-white data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=checked]:border-primary focus-visible:ring-2 focus-visible:ring-primary/30 size-4 shrink-0 rounded-[4px] shadow-sm transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+122
View File
@@ -0,0 +1,122 @@
import React, { useState } from "react";
import {
Bell,
ChevronDown,
Search,
Building2,
} from "lucide-react";
interface HeaderProps {
title: string; // Page title shown on the left
subtitle?: string; // Optional sub-label under the title
}
// Mock notifications — replace with Redux state when backend is ready
const MOCK_NOTIFICATIONS = 2;
// Mock user — replace with useSelector(state => state.auth.user) when available
const MOCK_USER = {
name: "John Doe",
role: "Admin",
initials: "JD",
company: "Acme Corp",
};
export function Header({ title, subtitle }: HeaderProps) {
const [notifOpen, setNotifOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
const [companyOpen, setCompanyOpen] = useState(false);
return (
<header
className="
h-16 w-full flex items-center justify-between px-6
bg-surface border-b border-border
sticky top-0 z-30
"
>
{/* ── Left: Page Title ──────────────────────────────── */}
<div>
<h1 className="text-base font-semibold text-foreground leading-tight">
{title}
</h1>
{subtitle && (
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
{subtitle}
</p>
)}
</div>
{/* ── Right: Controls ───────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Company Selector */}
<button
onClick={() => setCompanyOpen(o => !o)}
className="
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
text-foreground bg-surface-muted hover:bg-border
transition-colors border border-border
"
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<span>{MOCK_USER.company}</span>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</button>
{/* Notification Bell */}
<div className="relative">
<button
onClick={() => setNotifOpen(o => !o)}
className="
relative w-9 h-9 flex items-center justify-center rounded-lg
text-muted-foreground hover:bg-surface-muted hover:text-foreground
transition-colors border border-border
"
>
<Bell className="w-4 h-4" />
{MOCK_NOTIFICATIONS > 0 && (
<span
className="
absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1
flex items-center justify-center rounded-full
bg-primary text-white text-[10px] font-bold leading-none
"
>
{MOCK_NOTIFICATIONS}
</span>
)}
</button>
</div>
{/* User Avatar + Name */}
<button
onClick={() => setUserOpen(o => !o)}
className="
flex items-center gap-2.5 pl-2 pr-3 py-1.5 rounded-lg
hover:bg-surface-muted transition-colors border border-border
"
>
<div
className="
w-7 h-7 rounded-full flex items-center justify-center
bg-gradient-to-br from-primary to-primary-hover
text-white text-xs font-bold select-none
"
>
{MOCK_USER.initials}
</div>
<div className="text-left leading-tight hidden sm:block">
<div className="text-sm font-semibold text-foreground">
{MOCK_USER.name}
</div>
<div className="text-xs text-muted-foreground">
{MOCK_USER.role}
</div>
</div>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground hidden sm:block" />
</button>
</div>
</header>
);
}
+126
View File
@@ -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>
);
}
+171
View File
@@ -0,0 +1,171 @@
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 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 DOT_COLORS: Record<string, string> = {
green: "bg-green-500",
red: "bg-red-500",
amber: "bg-amber-400",
blue: "bg-blue-500",
};
// ── Component ─────────────────────────────────────────────────────────────────
export function KPI({
value,
label,
icon: Icon,
variant = "blue",
trendValue,
trendDirection,
dots,
className = "",
}: KPIProps) {
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}
`}
>
<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="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]}`}
/>
))}
</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 === "down" && <TrendingDown className="w-3.5 h-3.5" />}
{trendDirection === "flat" && <Minus className="w-3.5 h-3.5" />}
<span>{trendValue}</span>
</div>
)}
</div>
</div>
);
}
// ── 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) {
return (
<div
className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 ${className}`}
>
{children}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
pageSizeOptions?: number[];
onPageSizeChange?: (size: number) => void;
}
export function Pagination({
page,
pageSize,
total,
onPageChange,
pageSizeOptions = [10, 25, 50, 100],
onPageSizeChange,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
// Build page buttons — show max 5 pages centered on current page
const getPages = () => {
const pages: (number | "…")[] = [];
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push("…");
const lo = Math.max(2, page - 1);
const hi = Math.min(totalPages - 1, page + 1);
for (let i = lo; i <= hi; i++) pages.push(i);
if (page < totalPages - 2) pages.push("…");
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-white rounded-b-xl">
{/* Result count */}
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">
{total === 0 ? "No results" : `${start}${end} of ${total.toLocaleString()}`}
</span>
{onPageSizeChange && (
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="text-xs border border-gray-200 rounded-md px-2 py-1 text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{pageSizeOptions.map((s) => (
<option key={s} value={s}>{s} / page</option>
))}
</select>
)}
</div>
{/* Page buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPages().map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="w-8 text-center text-xs text-gray-400">
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p as number)}
className={`w-8 h-8 text-xs font-medium rounded-lg border transition-colors ${
p === page
? "bg-primary text-white border-primary"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useState } from "react";
import { IconButton } from "./Button";
// ── Icons (inline SVG to avoid extra deps) ────────────────────────────────────
const SearchIcon = () => (
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
);
const ClearIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const FilterIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
</svg>
);
const ListIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
);
const GridIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
);
// ── Types ─────────────────────────────────────────────────────────────────────
export type ViewMode = "list" | "grid";
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Show the Filters button */
showFilters?: boolean;
onFilterClick?: () => void;
filterCount?: number; // badge count on filter button
/** Show list/grid view toggle */
showViewToggle?: boolean;
viewMode?: ViewMode;
onViewChange?: (mode: ViewMode) => void;
className?: string;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function SearchBar({
value,
onChange,
placeholder = "Search...",
showFilters = false,
onFilterClick,
filterCount = 0,
showViewToggle = false,
viewMode = "list",
onViewChange,
className = "",
}: SearchBarProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{/* Search input */}
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<SearchIcon />
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="
w-full pl-9 pr-8 h-9 text-sm bg-white
border border-gray-200 rounded-lg
text-gray-800 placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
transition-colors
"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
<ClearIcon />
</button>
)}
</div>
{/* Filters button */}
{showFilters && (
<div className="relative">
<button
type="button"
onClick={onFilterClick}
className="
inline-flex items-center gap-2 h-9 px-3 text-sm font-medium
bg-white border border-gray-200 rounded-lg
text-gray-600 hover:bg-gray-50 hover:text-gray-800
transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20
"
>
<FilterIcon />
Filters
{filterCount > 0 && (
<span className="min-w-[18px] h-[18px] px-1 bg-primary text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{filterCount}
</span>
)}
</button>
</div>
)}
{/* List / Grid view toggle */}
{showViewToggle && onViewChange && (
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden">
<IconButton
icon={<ListIcon />}
onClick={() => onViewChange("list")}
active={viewMode === "list"}
variant="outline"
title="List view"
className="rounded-none border-0 border-r border-gray-200"
/>
<IconButton
icon={<GridIcon />}
onClick={() => onViewChange("grid")}
active={viewMode === "grid"}
variant="outline"
title="Grid view"
className="rounded-none border-0"
/>
</div>
)}
</div>
);
}
// ── useSearch hook ─────────────────────────────────────────────────────────────
// Handles the search state so pages don't need to manage it manually.
export function useSearch(initialValue = "") {
const [query, setQuery] = useState(initialValue);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const filter = <T extends Record<string, unknown>>(
items: T[],
keys: (keyof T)[],
): T[] => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter((item) =>
keys.some((k) => String(item[k] ?? "").toLowerCase().includes(q))
);
};
return { query, setQuery, viewMode, setViewMode, filter };
}
+284
View File
@@ -0,0 +1,284 @@
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>
);
}
+100
View File
@@ -0,0 +1,100 @@
// ── StatusBadge ───────────────────────────────────────────────────────────────
// Reusable colored pill badges for statuses across all feature tables.
export type BadgeVariant =
| "active"
| "draft"
| "disabled"
| "published"
| "pending"
| "archived"
| "error"
| "info"
| "success"
| "warning"
| "neutral";
const BADGE_STYLES: Record<BadgeVariant, string> = {
active: "bg-green-100 text-green-700 border-green-200",
draft: "bg-blue-100 text-blue-700 border-blue-200",
disabled: "bg-gray-100 text-gray-500 border-gray-200",
published: "bg-purple-100 text-purple-700 border-purple-200",
pending: "bg-amber-100 text-amber-700 border-amber-200",
archived: "bg-slate-100 text-slate-500 border-slate-200",
error: "bg-red-100 text-red-700 border-red-200",
info: "bg-sky-100 text-sky-700 border-sky-200",
success: "bg-emerald-100 text-emerald-700 border-emerald-200",
warning: "bg-orange-100 text-orange-700 border-orange-200",
neutral: "bg-gray-50 text-gray-600 border-gray-200",
};
const DOT_COLORS: Record<BadgeVariant, string> = {
active: "bg-green-500",
draft: "bg-blue-400",
disabled: "bg-gray-400",
published: "bg-purple-500",
pending: "bg-amber-500",
archived: "bg-slate-400",
error: "bg-red-500",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-orange-500",
neutral: "bg-gray-400",
};
interface StatusBadgeProps {
status: BadgeVariant;
label?: string; // Override the displayed text (defaults to capitalized status)
showDot?: boolean; // Show the colored dot (default: true)
size?: "sm" | "md";
className?: string;
}
export function StatusBadge({
status,
label,
showDot = true,
size = "md",
className = "",
}: StatusBadgeProps) {
const displayLabel = label ?? (status.charAt(0).toUpperCase() + status.slice(1));
const sizeClass = size === "sm" ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1";
return (
<span
className={`
inline-flex items-center gap-1.5 font-medium rounded-full border
${sizeClass} ${BADGE_STYLES[status]} ${className}
`}
>
{showDot && (
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${DOT_COLORS[status]}`} />
)}
{displayLabel}
</span>
);
}
// ── Generic Badge ─────────────────────────────────────────────────────────────
// For non-status use-cases like data type pills, counts, tags, etc.
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
import React from "react";
export function Badge({ children, variant = "neutral", className = "" }: BadgeProps) {
return (
<span
className={`
inline-flex items-center text-xs font-medium px-2 py-0.5
rounded border ${BADGE_STYLES[variant]} ${className}
`}
>
{children}
</span>
);
}
+352
View File
@@ -0,0 +1,352 @@
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,
};
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
interface AuthLayoutProps {
children: React.ReactNode;
}
/**
* AuthLayout
* Centered, full-screen layout for Login and ForgotPassword pages.
* No sidebar or header — purely for unauthenticated screens.
*/
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Brand mark */}
<div className="flex items-center justify-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center shadow-md">
<span className="text-white font-bold text-lg">P</span>
</div>
<div>
<div className="font-bold text-foreground text-lg leading-tight">PIM Platform</div>
<div className="text-xs text-muted-foreground">Enterprise Edition</div>
</div>
</div>
{/* Auth card */}
<div className="bg-surface border border-border rounded-2xl shadow-md p-8">
{children}
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { Sidebar } from '../customs/Sidebar';
import { Header } from '../customs/Headers';
interface MainLayoutProps {
activeView: string;
onNavigate: (view: string) => void;
title: string;
subtitle?: string;
children: React.ReactNode;
}
/**
* 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} />
{/* 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">
{children}
</main>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
interface PageWrapperProps {
children: React.ReactNode;
className?: string;
}
/**
* PageWrapper
* Consistent inner padding and max-width for all page content areas.
* Wrap the inner content of every page with this.
*
* Usage:
* <PageWrapper>
* <YourPageContent />
* </PageWrapper>
*/
export function PageWrapper({ children, className = '' }: PageWrapperProps) {
return (
<div className={`p-6 max-w-screen-2xl mx-auto ${className}`}>
{children}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { Box, AlertCircle, CheckCircle2, Radio } from "lucide-react";
import {
LineChart,
Line,
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";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
// ── Publication Trends data ───────────────────────────────────────────────────
const trendData = [
{ month: "Jan", products: 1200 },
{ month: "Feb", products: 1350 },
{ month: "Mar", products: 1280 },
{ month: "Apr", products: 1850 },
{ month: "May", products: 2150 },
{ month: "Jun", products: 2400 },
];
const trendConfig: ChartConfig = {
products: { label: "Products", color: "#7c3aed" },
};
// ── Channel Distribution data ─────────────────────────────────────────────────
const channelData = [
{ 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" },
];
// ── Dashboard ─────────────────────────────────────────────────────────────────
export default function Dashboard() {
return (
<PageWrapper>
{/* Welcome banner */}
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900">Welcome back, John</h2>
<p className="text-sm text-gray-500 mt-0.5">
Here's what's happening with your product catalog today.
</p>
</div>
{/* ── KPI InfoCards ── */}
<InfoCardGrid cols={4} className="mb-6">
<InfoCard
label="Total Products"
value="12,459"
icon={<Box className="w-5 h-5 text-purple-400" />}
trend="+12.5%"
trendDirection="up"
subtitle="vs. last month"
/>
<InfoCard
label="Pending Approvals"
value="8"
icon={<AlertCircle className="w-5 h-5 text-purple-400" />}
subtitle="Requires attention"
/>
<InfoCard
label="Published This Month"
value="2,380"
icon={<CheckCircle2 className="w-5 h-5 text-purple-400" />}
trend="+18.2%"
trendDirection="up"
subtitle="vs. last month"
/>
<InfoCard
label="Active Channels"
value="24"
icon={<Radio className="w-5 h-5 text-purple-400" />}
trend="+4.3%"
trendDirection="up"
subtitle="Publishing enabled"
/>
</InfoCardGrid>
{/* ── Charts row ── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 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>
<span className="text-xs text-gray-500 bg-gray-100 px-3 py-1.5 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors">
Last 6 months
</span>
</div>
<ChartContainer config={trendConfig} className="h-64">
<LineChart data={trendData} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="products"
stroke="#7c3aed"
strokeWidth={2.5}
dot={{ fill: "#7c3aed", r: 4, strokeWidth: 0 }}
activeDot={{ r: 6, fill: "#7c3aed" }}
/>
</LineChart>
</ChartContainer>
</div>
{/* Channel Distribution — Donut 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 */}
<div className="flex-shrink-0">
<ChartContainer config={{}} className="h-52 w-52">
<PieChart>
<Pie
data={channelData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={3}
dataKey="value"
>
{channelData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), "Products"]}
contentStyle={{
borderRadius: "8px",
border: "1px solid #e5e7eb",
fontSize: "12px",
}}
/>
</PieChart>
</ChartContainer>
</div>
{/* Legend */}
<div className="flex flex-col gap-3">
{channelData.map((item) => (
<div key={item.name} className="flex items-center gap-2.5">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<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>
</div>
))}
</div>
</div>
</div>
</div>
</PageWrapper>
);
}
+285 -50
View File
@@ -1,66 +1,301 @@
import React from 'react';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
import { usePermissions } from '../../../hooks/usePermission';
import { Can } from '../../../components/customs/Can';
import { useState } 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 { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Checkbox } from "../../../components/customs/CheckBox";
// Mock components to demonstrate button-level and table-level permissions
const AddButton = () => <button className="bg-primary text-white px-4 py-2 rounded">Create Product</button>;
const EditButton = () => <button className="text-info mx-1">Edit</button>;
const DeleteButton = () => <button className="text-danger mx-1">Delete</button>;
const ImportButton = () => <button className="bg-surface-2 text-text px-4 py-2 rounded mx-2">Import CSV</button>;
const ExportButton = () => <button className="bg-surface-2 text-text px-4 py-2 rounded">Export CSV</button>;
// ── Product icon (small purple hexagon) ────────────────────────────────────────
function ProductIcon() {
return (
<div className="w-9 h-9 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
<Box className="w-4 h-4 text-purple-500" />
</div>
);
}
const ProductList: React.FC = () => {
// Get all permission flags for this specific module
const { canCreate, canEdit, canDelete, canImport, canExport } = usePermissions('products.items');
// ── Mock data ──────────────────────────────────────────────────────────────────
type ProductStatus = "active" | "pending" | "draft" | "disabled";
interface Product {
id: string;
name: string;
category: string;
subcategory: string;
sku: string;
productId: string;
status: ProductStatus;
completeness: number;
stock: number;
price: string;
channels: { active: number; total: number };
updatedAt: string;
updatedBy: string;
}
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." },
];
const STATUS_MAP: Record<ProductStatus, "active" | "pending" | "draft" | "disabled"> = {
active: "active",
pending: "pending",
draft: "draft",
disabled: "disabled",
};
const STATUS_LABEL: Record<ProductStatus, string> = {
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 ──────────────────────────────────────────────────────────────────
export default function ProductList() {
const { canImport, canExport } = usePermissions("products.items");
const { query, setQuery, viewMode, setViewMode } = useSearch();
const [selected, setSelected] = useState<Set<string>>(new Set());
const q = query.toLowerCase();
const filtered = query.trim()
? MOCK_PRODUCTS.filter((p) =>
p.name.toLowerCase().includes(q) ||
p.sku.toLowerCase().includes(q) ||
p.productId.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q)
)
: MOCK_PRODUCTS;
// Selection helpers
const allSelected = filtered.length > 0 && selected.size === filtered.length;
const someSelected = selected.size > 0 && !allSelected;
function toggleAll() {
if (allSelected) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((p) => p.id)));
}
}
function 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 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;
return (
<ProtectedRoute node="products.items">
<div className="p-6">
<header className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Products Inventory</h1>
<div className="actions">
{/* 6. Button-level permissions (direct flag checking) */}
{canImport && <ImportButton />}
{canExport && <ExportButton />}
{canCreate && <AddButton />}
</div>
</header>
<PageWrapper>
{/* ── 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>
<div className="bg-surface rounded-lg shadow border border-border p-4">
{/* ── Page heading + actions ──────────────────── */}
<div className="flex items-start justify-between mb-5">
<div>
<h2 className="text-xl font-bold text-gray-900">Products</h2>
<p className="text-sm text-gray-500 mt-0.5">
{MOCK_PRODUCTS.length} total products in your catalog
</p>
</div>
<div className="flex items-center gap-2">
{canExport && <ExportButton />}
{canImport && <ImportButton />}
<Can node="products.items" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
Create Product
</Button>
</Can>
</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>
{/* ── SearchBar ──────────────────────────────── */}
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search by name, SKU, or Product ID..."
showFilters
showViewToggle
viewMode={viewMode}
onViewChange={setViewMode}
className="mb-5"
/>
{/* ── Bulk action bar ────────────────────────── */}
{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">
<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
onClick={() => setSelected(new Set())}
className="text-sm text-primary font-medium hover:underline ml-1"
>
Clear
</button>
</div>
</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 ─────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-border">
<th className="pb-3">Name</th>
<th className="pb-3">SKU</th>
<th className="pb-3">Actions</th>
<thead className="bg-gray-50/80 border-b border-gray-200">
<tr>
<th className="w-10 px-4 py-3">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
/>
</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Product</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">SKU / ID</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Status</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Completeness</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Stock</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Price</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Channels</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Updated</th>
</tr>
</thead>
<tbody>
{/* Mock Row */}
<tr className="border-b border-border">
<td className="py-3">Premium Wireless Headphones</td>
<td className="py-3">WH-1000XM5</td>
<td className="py-3">
{/* 7. Table action permissions (direct flags) */}
<div className="flex">
{canEdit && <EditButton />}
{canDelete && <DeleteButton />}
{filtered.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-16 text-center text-sm text-gray-400">
No products found matching "{query}"
</td>
</tr>
) : (
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" : ""}`}
>
{/* Checkbox */}
<td className="w-10 px-4 py-4">
<Checkbox
checked={selected.has(p.id)}
onCheckedChange={() => toggleOne(p.id)}
/>
</td>
{/* 8. Reusable permission wrapper component alternative */}
<Can node="products.items" action="alter">
<button className="text-warning mx-1">Advanced Alter</button>
</Can>
</div>
</td>
</tr>
{/* 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">
{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">
<div
className={`h-full rounded-full ${completenessColor(p.completeness)}`}
style={{ width: `${p.completeness}%` }}
/>
</div>
<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">
<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>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</PageWrapper>
</ProtectedRoute>
);
};
export default ProductList;
}
@@ -0,0 +1,78 @@
import { Grid3x3, CheckCircle2, Clock, XCircle, Globe, Upload, Download, RefreshCw, Plus } from "lucide-react";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { Button } from "../../../components/customs/Button";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
export default function VariantList() {
return (
<PageWrapper>
{/* Page heading + actions */}
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-900">Variant Management</h2>
<p className="text-sm text-gray-500 mt-0.5">
Manage all sellable SKUs across the product catalog
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="md" icon={<Upload className="w-4 h-4" />}>
Import
</Button>
<Button variant="outline" size="md" icon={<Download className="w-4 h-4" />}>
Export
</Button>
<Button variant="outline" size="md" icon={<RefreshCw className="w-4 h-4" />}>
Bulk Update
</Button>
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
Create Variant
</Button>
</div>
</div>
{/* ── InfoCards — 5 cols matching screenshot 2 ── */}
<InfoCardGrid cols={5} className="mb-8">
<InfoCard
label="Total Variants"
value="15"
icon={<Grid3x3 className="w-5 h-5 text-gray-400" />}
subtitle="All SKUs in catalog"
variant="default"
/>
<InfoCard
label="Active"
value="13"
icon={<CheckCircle2 className="w-5 h-5 text-green-500" />}
subtitle="87% of total"
variant="green"
/>
<InfoCard
label="Draft"
value="1"
icon={<Clock className="w-5 h-5 text-blue-400" />}
subtitle="Pending completion"
variant="blue"
/>
<InfoCard
label="Disabled"
value="1"
icon={<XCircle className="w-5 h-5 text-gray-400" />}
subtitle="Not available"
variant="default"
/>
<InfoCard
label="Published"
value="13"
icon={<Globe className="w-5 h-5 text-purple-400" />}
subtitle="Live on channels"
variant="purple"
/>
</InfoCardGrid>
{/* Placeholder for variant table */}
<div className="bg-white border border-gray-200 rounded-xl p-12 flex flex-col items-center justify-center text-center">
<p className="text-sm text-gray-400">Variant table will go here.</p>
</div>
</PageWrapper>
);
}
+68 -51
View File
@@ -1,59 +1,76 @@
@import "tailwindcss";
/* ============================================
PRODUCT CATALOG (PIM) — GLOBAL DESIGN TOKENS
All colors, fonts, spacing defined here.
Import in main.tsx only.
PIM PLATFORM — GLOBAL DESIGN SYSTEM
Tailwind v4 @theme block: registers all CSS
custom properties as Tailwind utility classes.
e.g. --color-primary → bg-primary, text-primary, border-primary
Change a value here and it reflects everywhere.
============================================ */
:root {
/* --- Brand Colors --- */
--color-primary: #4F46E5; /* Indigo 600 */
--color-primary-light: #818CF8; /* Indigo 400 */
--color-primary-dark: #3730A3; /* Indigo 800 */
/* --- Semantic Colors --- */
--color-success: #10B981;
--color-warning: #F59E0B;
--color-danger: #EF4444;
--color-info: #3B82F6;
/* --- Neutral Palette --- */
--color-bg: #0F172A; /* Dark background */
--color-surface: #1E293B; /* Card/panel surface */
--color-surface-2: #334155; /* Elevated surface */
--color-border: #334155;
--color-text: #F1F5F9;
--color-text-muted: #94A3B8;
--color-text-subtle: #64748B;
/* --- Spacing Scale --- */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
/* --- Border Radius --- */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* --- Font --- */
@theme {
/* --- Brand / Primary --- */
--color-primary: #7c3aed; /* purple-600 — main brand */
--color-primary-hover: #6d28d9; /* purple-700 — hover state */
--color-primary-light: #a78bfa; /* purple-400 — light accent */
--color-primary-dark: #4c1d95; /* purple-900 — dark accent */
/* --- Semantic --- */
--color-success: #10B981; /* emerald-500 */
--color-warning: #F59E0B; /* amber-500 */
--color-danger: #EF4444; /* red-500 */
--color-info: #3B82F6; /* blue-500 */
/* --- Surface & Background --- */
--color-background: #f9fafb; /* gray-50 — app bg */
--color-surface: #ffffff; /* white — card/panel */
--color-surface-muted: #f3f4f6; /* gray-100 — muted surface */
/* --- Text --- */
--color-foreground: #111827; /* gray-900 — primary text */
--color-muted-foreground: #6b7280; /* gray-500 — secondary text */
--color-subtle-foreground: #9ca3af; /* gray-400 — placeholder */
/* --- Borders --- */
--color-border: #e5e7eb; /* gray-200 */
--color-border-strong: #d1d5db; /* gray-300 */
/* --- Sidebar-specific (override for light sidebar) --- */
--color-sidebar-bg: #ffffff;
--color-sidebar-text: #374151; /* gray-700 */
--color-sidebar-muted: #6b7280; /* gray-500 */
/* --- Font families --- */
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
/* --- Shadows --- */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.4);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.5);
/* --- Sidebar Width --- */
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--header-height: 64px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
--shadow-md: 0 4px 6px rgba(0,0,0,0.08);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.10);
}
/* ============================================
LAYOUT CONSTANTS (used in JS/CSS via var())
These are NOT Tailwind utilities, just CSS vars.
============================================ */
:root {
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--header-height: 64px;
}
/* ============================================
BASE RESET
============================================ */
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background-color: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
margin: 0;
}
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Utility to merge Tailwind CSS classes without conflicts.
* Usage: cn("px-4 py-2", isActive && "bg-primary", className)
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}