create custom modules
This commit is contained in:
Generated
+545
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InfoCardVariant =
|
||||
| "default" // white, gray border (Dashboard style)
|
||||
| "green" // light green tint (Active)
|
||||
| "blue" // light blue tint (Draft)
|
||||
| "amber" // light amber tint (Warning)
|
||||
| "red" // light red tint (Disabled)
|
||||
| "purple"; // light purple tint (Primary)
|
||||
|
||||
export interface InfoCardProps {
|
||||
/** Small uppercase label at top-left */
|
||||
label: string;
|
||||
/** Large stat value */
|
||||
value: string | number;
|
||||
/** Icon element rendered at top-right */
|
||||
icon: ReactNode;
|
||||
/** Optional inline trend e.g. "+12.5%" — shown right next to value */
|
||||
trend?: string;
|
||||
/** Trend direction colours the text: "up"=green, "down"=red, "neutral"=gray */
|
||||
trendDirection?: "up" | "down" | "neutral";
|
||||
/** Subtitle / helper text below value */
|
||||
subtitle?: string;
|
||||
/** Colour variant */
|
||||
variant?: InfoCardVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ── Style maps ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CARD_STYLES: Record<InfoCardVariant, string> = {
|
||||
default: "bg-white border border-gray-200",
|
||||
green: "bg-green-50 border border-green-200",
|
||||
blue: "bg-blue-50 border border-blue-200",
|
||||
amber: "bg-amber-50 border border-amber-200",
|
||||
red: "bg-red-50 border border-red-200",
|
||||
purple: "bg-purple-50 border border-purple-200",
|
||||
};
|
||||
|
||||
const TREND_STYLES: Record<string, string> = {
|
||||
up: "text-green-600",
|
||||
down: "text-red-500",
|
||||
neutral: "text-gray-500",
|
||||
};
|
||||
|
||||
// ── InfoCard ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InfoCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
trendDirection = "up",
|
||||
subtitle,
|
||||
variant = "default",
|
||||
className = "",
|
||||
}: InfoCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative rounded-xl p-5 flex flex-col gap-3
|
||||
${CARD_STYLES[variant]} ${className}
|
||||
`}
|
||||
>
|
||||
{/* Row 1: Label + Icon */}
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Value + inline trend */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-gray-900 leading-none">
|
||||
{value}
|
||||
</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`
|
||||
text-xs font-semibold flex items-center gap-0.5
|
||||
${TREND_STYLES[trendDirection]}
|
||||
`}
|
||||
>
|
||||
{trendDirection === "up" && "↗"}
|
||||
{trendDirection === "down" && "↘"}
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Subtitle */}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 leading-tight">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── InfoCardGrid ──────────────────────────────────────────────────────────────
|
||||
// Responsive grid wrapper. Default: 4 cols on lg, 2 on sm, 1 on xs.
|
||||
|
||||
interface InfoCardGridProps {
|
||||
children: ReactNode;
|
||||
cols?: 4 | 5 | 3 | 2;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GRID_COLS: Record<number, string> = {
|
||||
2: "grid-cols-1 sm:grid-cols-2",
|
||||
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
||||
5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
|
||||
};
|
||||
|
||||
export function InfoCardGrid({ children, cols = 4, className = "" }: InfoCardGridProps) {
|
||||
return (
|
||||
<div className={`grid gap-4 ${GRID_COLS[cols]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user