solved merge conflicts

This commit is contained in:
m7338ohd-debug
2026-07-18 16:47:17 +05:30
81 changed files with 2084 additions and 2442 deletions
+327 -40
View File
@@ -302,9 +302,9 @@
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -313,9 +313,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -639,6 +639,17 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz",
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"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",
@@ -1358,6 +1369,278 @@
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz",
"integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz",
"integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz",
"integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz",
"integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz",
"integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz",
"integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz",
"integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz",
"integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz",
"integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz",
"integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz",
"integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz",
"integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz",
"integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "1.11.2",
"@emnapi/runtime": "1.11.2",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz",
"integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz",
"integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/plugin-babel": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.3.tgz",
@@ -1393,7 +1676,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
@@ -1838,7 +2120,7 @@
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -1848,7 +2130,6 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1858,7 +2139,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"
@@ -2402,7 +2683,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -2949,7 +3229,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -3079,7 +3358,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3810,7 +4088,6 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -3923,14 +4200,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3943,7 +4218,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -4288,6 +4562,41 @@
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz",
"integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.140.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.2.0",
"@rolldown/binding-darwin-arm64": "1.2.0",
"@rolldown/binding-darwin-x64": "1.2.0",
"@rolldown/binding-freebsd-x64": "1.2.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.0",
"@rolldown/binding-linux-arm64-gnu": "1.2.0",
"@rolldown/binding-linux-arm64-musl": "1.2.0",
"@rolldown/binding-linux-ppc64-gnu": "1.2.0",
"@rolldown/binding-linux-s390x-gnu": "1.2.0",
"@rolldown/binding-linux-x64-gnu": "1.2.0",
"@rolldown/binding-linux-x64-musl": "1.2.0",
"@rolldown/binding-openharmony-arm64": "1.2.0",
"@rolldown/binding-wasm32-wasi": "1.2.0",
"@rolldown/binding-win32-arm64-msvc": "1.2.0",
"@rolldown/binding-win32-x64-msvc": "1.2.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4421,7 +4730,6 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -4537,7 +4845,7 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -4659,7 +4967,6 @@
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
@@ -4737,7 +5044,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4749,7 +5055,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4760,7 +5065,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4771,7 +5075,6 @@
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -4784,7 +5087,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4801,7 +5103,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4818,7 +5119,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4835,7 +5135,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4852,7 +5151,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4869,7 +5167,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4886,7 +5183,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4903,7 +5199,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4920,7 +5215,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4937,7 +5231,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4954,7 +5247,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4971,7 +5263,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4988,7 +5279,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -5007,7 +5297,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5024,7 +5313,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5038,7 +5326,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.133.0",
+22
View File
@@ -1,4 +1,5 @@
// src/components/customs/ActionMenu.tsx
import { type ReactNode } from "react";
import { MoreHorizontal, Eye, Edit2, Trash2 } from "lucide-react";
import {
DropdownMenu,
@@ -7,6 +8,13 @@ import {
DropdownMenuTrigger,
} from "./Dropdown";
export interface CustomAction {
label: string;
icon?: ReactNode;
onClick: () => void;
className?: string;
}
export interface ActionMenuItem {
label: string;
icon?: React.ReactNode;
@@ -17,6 +25,7 @@ export interface ActionMenuProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
customActions?: CustomAction[];
customActions?: ActionMenuItem[];
}
@@ -66,6 +75,19 @@ export function ActionMenu({ onView, onEdit, onDelete, customActions }: ActionMe
<span className="font-medium text-sm">Edit</span>
</DropdownMenuItem>
)}
{customActions && customActions.map((action, idx) => (
<DropdownMenuItem
key={idx}
onClick={(e) => { e.stopPropagation(); action.onClick(); }}
className={`cursor-pointer rounded-lg px-3 py-2 flex items-center outline-none mt-1 transition-colors ${action.className || ''}`}
style={{ color: "var(--color-table-header-text)" }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = "var(--color-table-header-bg)")}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = "transparent")}
>
{action.icon && <span className="mr-2 flex items-center shrink-0">{action.icon}</span>}
<span className="font-medium text-sm">{action.label}</span>
</DropdownMenuItem>
))}
{customActions && customActions.map((action, idx) => (
<DropdownMenuItem
key={idx}
+5 -2
View File
@@ -3,7 +3,7 @@ import {
Search, X, ChevronsUpDown, ChevronUp, ChevronDown,
ChevronLeft, ChevronRight, Settings2, Check, Filter
} from "lucide-react";
import { ActionMenu, type ActionMenuItem } from "./ActionMenu";
import { ActionMenu, type CustomAction, type ActionMenuItem } from "./ActionMenu";
export interface DataTableColumn<T = any> {
key: string;
@@ -18,6 +18,7 @@ export interface DataTableColumn<T = any> {
export interface DataTableProps<T = any> {
columns: DataTableColumn<T>[];
data: T[];
loading?: boolean;
selectable?: boolean;
selectedIds?: Set<string>;
onSelectionChange?: (ids: Set<string>) => void;
@@ -37,6 +38,7 @@ export interface DataTableProps<T = any> {
onView?: (row: T) => void;
onEdit?: (row: T) => void;
onDelete?: (row: T) => void;
customActions?: (row: T) => CustomAction[];
customActions?: (row: T) => ActionMenuItem[];
};
}
@@ -59,6 +61,7 @@ export function DataTable<T extends Record<string, any> = any>({
statusKey = "status",
loading,
actionConfig,
loading = false,
}: DataTableProps<T>) {
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
@@ -367,7 +370,7 @@ export function DataTable<T extends Record<string, any> = any>({
<input
type="checkbox"
checked={selectedIds.has(id)}
onChange={() => {}}
onChange={() => { }}
className="w-4 h-4 accent-primary cursor-pointer"
/>
</td>
+2
View File
@@ -21,6 +21,7 @@ interface SelectProps {
}
export function Select({
id,
name,
value,
onChange,
@@ -98,6 +99,7 @@ export function Select({
<div className="relative w-full">
<button
ref={buttonRef}
id={id}
type="button"
disabled={disabled}
onClick={handleToggle}
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ProductCardProps {
interface StatsCardProps {
title: string;
value: string | number;
subtitle: string;
@@ -54,7 +54,7 @@ const COLOR_MAP = {
},
};
export function FamilyCard({ title, value, subtitle, icon, color }: ProductCardProps) {
export function StatsCard({ title, value, subtitle, icon, color }: StatsCardProps) {
const c = COLOR_MAP[color];
return (
<div
+3
View File
@@ -5,6 +5,7 @@ import React from "react";
export type BadgeVariant =
| "active"
| "draft"
| "inactive"
| "disabled"
| "inactive"
| "published"
@@ -22,6 +23,7 @@ export type BadgeVariant =
const BADGE_STYLES: Record<BadgeVariant, string> = {
active: "bg-green-100 text-green-700 border border-green-200",
draft: "bg-slate-100 text-slate-700 border border-slate-200",
inactive: "bg-zinc-100 text-zinc-500 border border-zinc-200",
disabled: "bg-gray-100 text-gray-500 border border-gray-200",
inactive: "bg-gray-100 text-gray-500 border border-gray-200",
published: "bg-primary-light text-primary-dark border border-primary/20",
@@ -40,6 +42,7 @@ const BADGE_STYLES: Record<BadgeVariant, string> = {
const DOT_COLORS: Record<BadgeVariant, string> = {
active: "bg-green-500",
draft: "bg-slate-400",
inactive: "bg-zinc-400",
disabled: "bg-gray-400",
inactive: "bg-gray-400",
published: "bg-primary",
+4 -4
View File
@@ -15,7 +15,7 @@ export function Header() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user } = useAppSelector((state) => state.auth);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [headerUnread, setHeaderUnread] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
@@ -118,7 +118,7 @@ export function Header() {
</div>
<div className="relative" ref={menuRef}>
<button
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg hover:bg-surface-muted"
>
@@ -126,8 +126,8 @@ export function Header() {
{user?.name
? user.name.substring(0, 2).toUpperCase()
: user?.email
? user.email.substring(0, 2).toUpperCase()
: "U"}
? user.email.substring(0, 2).toUpperCase()
: "U"}
</div>
<div className="text-left">
<div className="text-sm font-semibold">
+2 -2
View File
@@ -107,7 +107,7 @@ export function Sidebar() {
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group text-sm font-medium ${isActive || hasActiveChild
? "bg-primary/10 text-primary shadow-sm"
: "text-foreground/70 hover:bg-primary/5"
: "text-foreground/70 hover:bg-primary-ghost"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
@@ -138,7 +138,7 @@ export function Sidebar() {
onClick={() => navigate(child.href)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all group text-sm ${isChildActiveItem
? "bg-primary/10 text-primary font-medium"
: "text-foreground/60 hover:bg-primary/5"
: "text-foreground/60 hover:bg-primary-ghost"
}`}
>
<ChildIcon className="w-4 h-4 flex-shrink-0" />
+5
View File
@@ -9,6 +9,7 @@ export const RoyalPurpleTheme: Theme = {
primary: "#7C3AED",
primaryHover: "#6D28D9",
primaryLight: "#DDD6FE",
primaryGhost: "#EDE9FE", /* violet-100 */
primaryDark: "#4C1D95",
background: "#f9fafb",
surface: "#ffffff",
@@ -29,6 +30,7 @@ export const ForestGreenTheme: Theme = {
primary: "#16A34A",
primaryHover: "#15803D",
primaryLight: "#BBF7D0",
primaryGhost: "#DCFCE7", /* green-100 */
primaryDark: "#166534",
background: "#f9fafb",
surface: "#ffffff",
@@ -49,6 +51,7 @@ export const OceanBlueTheme: Theme = {
primary: "#2563EB",
primaryHover: "#1D4ED8",
primaryLight: "#BFDBFE",
primaryGhost: "#DBEAFE", /* blue-100 */
primaryDark: "#1E3A8A",
background: "#f9fafb",
surface: "#ffffff",
@@ -69,6 +72,7 @@ export const SunsetOrangeTheme: Theme = {
primary: "#EA580C",
primaryHover: "#C2410C",
primaryLight: "#FED7AA",
primaryGhost: "#FFEDD5", /* orange-100 */
primaryDark: "#7C2D12",
background: "#f9fafb",
surface: "#ffffff",
@@ -89,6 +93,7 @@ export const DarkTheme: Theme = {
primary: "#1F2937",
primaryHover: "#374151",
primaryLight: "#4B5563",
primaryGhost: "#374151", /* gray-700 — dark mode subtle hover */
primaryDark: "#111827",
background: "#111827",
surface: "#1F2937",
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
import { assetFamiliesService } from '../services/asset-families.service';
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
import { notify } from '../../../services/toast';
@@ -13,7 +14,11 @@ export const useAssetFamily = () => {
const data = await assetFamiliesService.getAll();
setItems(data);
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
} finally {
setLoading(false);
}
@@ -27,7 +32,11 @@ export const useAssetFamily = () => {
notify.success('Asset family created successfully!');
return created;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -42,7 +51,11 @@ export const useAssetFamily = () => {
notify.success('Asset family updated successfully!');
return updated;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -56,7 +69,11 @@ export const useAssetFamily = () => {
setItems((prev) => prev.filter((p) => p.id !== id));
notify.success('Asset family deleted successfully!');
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -1,3 +1,5 @@
import type { AssetType } from '../../asset-types/types/asset-types.types';
export interface AssetFamily {
id: string;
name: string;
@@ -5,8 +7,11 @@ export interface AssetFamily {
description?: string;
status: 'active' | 'inactive';
createdAt: string;
assetTypes?: AssetType[];
assetTypeIds?: string[];
assetTypes?: any[];
assetTypeIds?: string[];
}
export type AssetFamilyCreateRequest = Omit<AssetFamily, 'id' | 'createdAt'>;
export type AssetFamilyUpdateRequest = Partial<AssetFamilyCreateRequest>;
@@ -1,98 +0,0 @@
import type { ReactNode } from "react";
/**
* AssetTypeStatsCard a premium styled card component mirroring the visual design of ProductCard.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface AssetTypeStatsCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function AssetTypeStatsCard({ title, value, subtitle, icon, color }: AssetTypeStatsCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+21 -4
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
import { assetTypesService } from '../services/asset-types.service';
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
import { notify } from '../../../services/toast';
@@ -13,7 +14,11 @@ export const useAssetType = () => {
const data = await assetTypesService.getAll();
setItems(data);
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
} finally {
setLoading(false);
}
@@ -27,7 +32,11 @@ export const useAssetType = () => {
notify.success('Asset type created successfully!');
return created;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -42,7 +51,11 @@ export const useAssetType = () => {
notify.success('Asset type updated successfully!');
return updated;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -56,7 +69,11 @@ export const useAssetType = () => {
setItems((prev) => prev.filter((p) => p.id !== id));
notify.success('Asset type deleted successfully!');
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -5,7 +5,7 @@ import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { AssetTypeStatsCard } from "../components/AssetTypeStatsCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useAssetType } from "../hook/useAssetType";
import type { AssetType } from "../types/asset-types.types";
@@ -61,28 +61,28 @@ export default function AssetTypeList() {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<AssetTypeStatsCard
<StatsCard
title="Total Asset Types"
value={items.length}
subtitle="across all categories"
color="purple"
icon={<LayoutGrid className="w-5 h-5" />}
/>
<AssetTypeStatsCard
<StatsCard
title="Active Types"
value={activeCount}
subtitle={`${inactiveCount} inactive`}
color="green"
icon={<CheckCircle2 className="w-5 h-5" />}
/>
<AssetTypeStatsCard
<StatsCard
title="Categories Configured"
value={new Set(items.map(i => i.category || 'other')).size}
subtitle="media taxonomies"
color="blue"
icon={<Layers className="w-5 h-5" />}
/>
<AssetTypeStatsCard
<StatsCard
title="Required Types"
value={items.filter(i => i.isRequired).length}
subtitle="enforced for completeness"
@@ -1,60 +0,0 @@
import type { ReactNode } from "react";
interface AssetCardProps {
title: string;
value: string | number;
subtitle: string;
icon?: ReactNode;
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
export function AssetCard({ title, value, subtitle, icon, color }: AssetCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+19 -23
View File
@@ -11,14 +11,14 @@ import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { AssetCard } from "../components/AssetCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useAsset } from "../hook/useAsset";
import { assetsService, type AssetRelationInfo, type AssetAnalytics, type AssetMapping } from "../services/assets.service";
import { productService } from "../../product/services/product.service";
import type { Asset } from "../types/assets.types";
import type { Product } from "../../product/types/product.types";
import { toast } from "react-toastify";
import { notify } from "../../../services/toast";
import apiClient from "../../../api/axiosInstance";
export default function AssetList() {
@@ -26,11 +26,7 @@ export default function AssetList() {
const {
items,
fetchItems,
deleteItem,
replaceAsset,
archiveAsset,
restoreAsset
} = useAsset();
deleteItem, replaceAsset, archiveAsset, restoreAsset } = useAsset();
const [activeTab, setActiveTab] = useState("Product Assets");
@@ -106,7 +102,7 @@ export default function AssetList() {
setLoadingAnalytics(true);
assetsService.getAnalytics()
.then(data => setAnalyticsData(data))
.catch(() => toast.error("Failed to load analytics data"))
.catch(() => notify.error("Failed to load analytics data"))
.finally(() => setLoadingAnalytics(false));
}
}, [activeTab]);
@@ -121,7 +117,7 @@ export default function AssetList() {
setSelectedProductId(data[0].id);
}
})
.catch(() => toast.error("Failed to load products list"));
.catch(() => notify.error("Failed to load products list"));
}
}, [activeTab, selectedProductId]);
@@ -138,7 +134,7 @@ export default function AssetList() {
setVariantAssets([]);
}
})
.catch(() => toast.error("Failed to load variants"));
.catch(() => notify.error("Failed to load variants"));
}
}, [selectedProductId]);
@@ -148,7 +144,7 @@ export default function AssetList() {
setLoadingVariantAssets(true);
assetsService.getVariantAssets(selectedVariantId)
.then(data => setVariantAssets(data))
.catch(() => toast.error("Failed to load variant assets"))
.catch(() => notify.error("Failed to load variant assets"))
.finally(() => setLoadingVariantAssets(false));
} else {
setVariantAssets([]);
@@ -241,12 +237,12 @@ export default function AssetList() {
checksum: `DIR-CHK-${Date.now()}`,
folder_path: newFolderName
});
toast.success(`Folder "${newFolderName}" created successfully!`);
notify.success(`Folder "${newFolderName}" created successfully!`);
setNewFolderName("");
setShowCreateFolder(false);
loadLibraryData();
} catch (err) {
toast.error("Failed to create folder");
notify.error("Failed to create folder");
}
};
@@ -258,7 +254,7 @@ export default function AssetList() {
const data = await assetsService.getRelations(id);
setRelationViewData(data);
} catch (err) {
toast.error("Failed to retrieve asset mappings");
notify.error("Failed to retrieve asset mappings");
} finally {
setLoadingRelationView(false);
}
@@ -298,7 +294,7 @@ export default function AssetList() {
}
}
toast.info(`Bulk execution completed: ${successCount} successful, ${failedCount} failed.`);
notify.info(`Bulk execution completed: ${successCount} successful, ${failedCount} failed.`);
setBulkSelectedIds([]);
fetchItems();
};
@@ -312,13 +308,13 @@ export default function AssetList() {
is_primary: assignIsPrimary,
display_order: assignDisplayOrder
});
toast.success("Asset assigned to variant successfully!");
notify.success("Asset assigned to variant successfully!");
setShowAssignPicker(false);
// Reload assets
const refreshed = await assetsService.getVariantAssets(selectedVariantId);
setVariantAssets(refreshed);
} catch (err: any) {
toast.error(err.response?.data?.message || "Failed to assign asset");
notify.error(err.response?.data?.message || "Failed to assign asset");
}
};
@@ -326,10 +322,10 @@ export default function AssetList() {
const handleUnassignVariantAsset = async (assetId: string) => {
try {
await assetsService.unassignVariantAsset(selectedVariantId, assetId);
toast.success("Asset unassigned successfully!");
notify.success("Asset unassigned successfully!");
setVariantAssets(prev => prev.filter(v => v.asset_id !== assetId));
} catch (err) {
toast.error("Failed to unassign asset");
notify.error("Failed to unassign asset");
}
};
@@ -407,28 +403,28 @@ export default function AssetList() {
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<AssetCard
<StatsCard
title="Total Assets"
value={stats.total}
subtitle="All database assets"
icon={<Package className="w-5 h-5" />}
color="purple"
/>
<AssetCard
<StatsCard
title="Active Assets"
value={stats.active}
subtitle="Currently in use"
icon={<TrendingUp className="w-5 h-5" />}
color="green"
/>
<AssetCard
<StatsCard
title="Draft Assets"
value={stats.draft}
subtitle="In review state"
icon={<AlertTriangle className="w-5 h-5" />}
color="red"
/>
<AssetCard
<StatsCard
title="Inactive Assets"
value={stats.inactive}
subtitle="Archived/Disabled"
+4 -4
View File
@@ -10,7 +10,7 @@ import { assetsService } from "../services/assets.service";
import type { AssetCreateRequest } from "../types/assets.types";
import { Save, Upload, FileText, Video, Loader2, Trash2, AlertCircle } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-primary'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
@@ -39,7 +39,7 @@ export default function NewAsset() {
onSubmit: async (values, { setSubmitting }) => {
try {
if (!values.file_url) {
toast.error("Please upload a file first");
notify.error("Please upload a file first");
return;
}
@@ -94,9 +94,9 @@ export default function NewAsset() {
if (!formik.values.name) {
formik.setFieldValue("name", file.name.replace(/\.[^/.]+$/, ""));
}
toast.success("File uploaded successfully!");
notify.success("File uploaded successfully!");
} catch (err: any) {
toast.error(err.response?.data?.message || err.message || "Failed to upload file");
notify.error(err.response?.data?.message || err.message || "Failed to upload file");
} finally {
setUploading(false);
}
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
import { attributeGroupsService } from '../services/attribute-groups.service';
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
import { notify } from '../../../services/toast';
@@ -13,7 +14,11 @@ export const useAttributeGroup = () => {
const data = await attributeGroupsService.getAll(query);
setItems(data);
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
} finally {
setLoading(false);
}
@@ -27,7 +32,11 @@ export const useAttributeGroup = () => {
notify.success('Attribute group created successfully!');
return created;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -42,7 +51,11 @@ export const useAttributeGroup = () => {
notify.success('Attribute group updated successfully!');
return updated;
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -56,7 +69,11 @@ export const useAttributeGroup = () => {
setItems((prev) => prev.filter((p) => p.id !== id));
notify.success('Attribute group deleted successfully!');
} catch (err: any) {
notify.error(err.response?.data?.message || err);
if (axios.isAxiosError(err)) {
notify.error(err.response?.data?.message || err.message);
} else {
notify.error(err instanceof Error ? err.message : String(err));
}
throw err;
} finally {
setLoading(false);
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { Plus, Save, Info, Check } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Button } from "../../../components/customs/Button";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { useAttributeGroup } from "../hook/useAttributeGroup";
@@ -12,10 +13,10 @@ import { useAttribute } from "../../attributes/hook/useAttribute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "select", label: "Attribute Selection", step: 2 },
{ id: "ordering", label: "Attribute Ordering", step: 3 },
{ id: "preview", label: "Preview", step: 4 },
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "select", label: "Attribute Selection", step: 2 },
{ id: "ordering", label: "Attribute Ordering", step: 3 },
{ id: "preview", label: "Preview", step: 4 },
];
const inputClass = (error?: boolean) =>
@@ -38,7 +39,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewAttributeGroup() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createItem, updateItem, items, fetchItems } = useAttributeGroup();
const { attributes, fetchAttributes } = useAttribute();
@@ -69,13 +72,13 @@ export default function NewAttributeGroup() {
},
});
useEffect(() => {
fetchItems();
useEffect(() => {
fetchItems();
fetchAttributes();
}, [fetchItems, fetchAttributes]);
useEffect(() => {
if (isEdit && id && items.length > 0) {
if ((isEdit || isView) && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
@@ -90,7 +93,7 @@ export default function NewAttributeGroup() {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
}, [isEdit, isView, id, items]);
const filteredAttributes = attributes.filter((a) =>
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -108,14 +111,19 @@ export default function NewAttributeGroup() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<PageWrapper>
<ProtectedRoute node="products.attributes">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Attribute Groups", href: "/attribute-groups" }, { label: isEdit ? "Edit Attribute Group" : "Create Attribute Group" }]}
items={[{ label: "Home" }, { label: "Attribute Groups", href: "/attribute-groups" }, { label: isView ? "View Attribute Group" : isEdit ? "Edit Attribute Group" : "Create Attribute Group" }]}
backTo="/attribute-groups"
actions={
<>
<Button variant="outline" size="md" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="attribute-group-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Group</Button>
<Button variant="outline" size="md" onClick={() => navigate("..")}>
{isView ? "Back" : "Cancel"}
</Button>
{!isView && (
<Button variant="primary" size="md" type="submit" form="attribute-group-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Group</Button>
)}
</>
}
/>
@@ -142,19 +150,18 @@ export default function NewAttributeGroup() {
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -170,9 +177,8 @@ export default function NewAttributeGroup() {
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
</button>
</div>
);
@@ -182,7 +188,8 @@ export default function NewAttributeGroup() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
<fieldset disabled={isView} className="contents">
<div className="overflow-y-auto">
{/* Step 1 — Basic Information */}
{activeStep === "basic" && (
@@ -368,8 +375,9 @@ export default function NewAttributeGroup() {
</div>
)}
</div>
</div>
</fieldset>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
@@ -383,5 +391,6 @@ export default function NewAttributeGroup() {
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -7,5 +7,6 @@ export const AttributeGroupRoutes = () => (
<Route index element={<AttributeGroupList />} />
<Route path="new" element={<NewAttributeGroup />} />
<Route path=":id/edit" element={<NewAttributeGroup />} />
<Route path=":id/view" element={<NewAttributeGroup />} />
</Routes>
);
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { attributeSetsService } from '../services/attribute-sets.service';
import type { AttributeSet, AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
export const useAttributeSet = () => {
const [items, setItems] = useState<AttributeSet[]>([]);
@@ -13,7 +13,7 @@ export const useAttributeSet = () => {
const data = await attributeSetsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch attribute sets');
notify.error(err);
} finally {
setLoading(false);
}
@@ -24,10 +24,10 @@ export const useAttributeSet = () => {
try {
const created = await attributeSetsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Attribute Set created successfully!');
notify.success('Attribute Set created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create attribute set');
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -39,10 +39,10 @@ export const useAttributeSet = () => {
try {
const updated = await attributeSetsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Attribute Set updated successfully!');
notify.success('Attribute Set updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update attribute set');
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -54,10 +54,10 @@ export const useAttributeSet = () => {
try {
await attributeSetsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Attribute Set deleted successfully!');
notify.success('Attribute Set deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete attribute set');
notify.error(err);
throw err;
} finally {
setLoading(false);
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
import { Plus, Upload, Download } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
import { Button } from '../../../components/customs/Button';
import { StatusBadge } from '../../../components/customs/StatusBadge';
import { DataTable } from '../../../components/customs/DataTable';
@@ -91,7 +92,8 @@ export default function AttributeSetList() {
];
return (
<PageWrapper>
<ProtectedRoute node="products.attributes">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Attribute Management' }, { label: 'Attribute Sets' }]}
actions={
@@ -144,5 +146,6 @@ export default function AttributeSetList() {
onCancel={() => setDeleteModal({ isOpen: false, id: '', name: '' })}
/>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { Plus, Save, Info, Check } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Button } from "../../../components/customs/Button";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { useAttributeSet } from "../hook/useAttributeSet";
@@ -10,6 +11,7 @@ import { useAttributeGroup } from "../../attribute-groups/hook/useAttributeGroup
import { attributeSetSchema } from "../validation/attribute-sets.schema";
import type { AttributeSetCreateRequest } from "../types/attribute-sets.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { generateCodeFromName } from "../../../utils/validators";
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
@@ -38,7 +40,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewAttributeSet() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createItem, updateItem, items, fetchItems } = useAttributeSet();
const { items: attributeGroups, fetchItems: fetchGroups } = useAttributeGroup();
@@ -74,7 +78,7 @@ export default function NewAttributeSet() {
}, [fetchItems, fetchGroups]);
useEffect(() => {
if (isEdit && id && items.length > 0) {
if ((isEdit || isView) && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
@@ -89,7 +93,7 @@ export default function NewAttributeSet() {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
}, [isEdit, isView, id, items]);
const filteredGroups = attributeGroups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -107,14 +111,19 @@ export default function NewAttributeSet() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<PageWrapper>
<ProtectedRoute node="products.attributes">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Attribute Sets", href: "/attribute-sets" }, { label: isEdit ? "Edit Attribute Set" : "Create Attribute Set" }]}
items={[{ label: "Home" }, { label: "Attribute Sets", href: "/attribute-sets" }, { label: isView ? "View Attribute Set" : isEdit ? "Edit Attribute Set" : "Create Attribute Set" }]}
backTo="/attribute-sets"
actions={
<>
<Button variant="outline" size="md" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="attribute-set-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Set</Button>
<Button variant="outline" size="md" onClick={() => navigate("..")}>
{isView ? "Back" : "Cancel"}
</Button>
{!isView && (
<Button variant="primary" size="md" type="submit" form="attribute-set-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Set</Button>
)}
</>
}
/>
@@ -181,7 +190,8 @@ export default function NewAttributeSet() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
<fieldset disabled={isView} className="contents">
<div className="overflow-y-auto">
{/* Step 1 — Basic Information */}
{activeStep === "basic" && (
@@ -208,7 +218,12 @@ export default function NewAttributeSet() {
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
if (!isEdit) {
formik.setFieldValue("code", generateCodeFromName(e.target.value));
}
}}
onBlur={formik.handleBlur}
placeholder="e.g., Electronic Accessories Set"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
@@ -358,8 +373,9 @@ export default function NewAttributeSet() {
</div>
)}
</div>
</div>
</fieldset>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
@@ -373,5 +389,6 @@ export default function NewAttributeSet() {
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -7,5 +7,6 @@ export const AttributeSetRoutes = () => (
<Route index element={<AttributeSetList />} />
<Route path="new" element={<NewAttributeSet />} />
<Route path=":id/edit" element={<NewAttributeSet />} />
<Route path=":id/view" element={<NewAttributeSet />} />
</Routes>
);
@@ -1,61 +0,0 @@
import type { ReactNode } from "react";
/**
* AttributeCard a premium styled card component mirroring the visual design of ProductCard.
* Displays a title, a prominent value, a subtitle, and an optional icon.
* The `color` prop selects a soft background gradient and border, matching the existing colour palette.
*/
interface AttributeCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
export function AttributeCard({ title, value, subtitle, icon, color }: AttributeCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">{title}</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">{value}</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">{subtitle}</div>
</div>
);
}
@@ -3,6 +3,7 @@ import { attributeService } from '../services/attribute.service';
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
import { notify } from '../../../services/toast';
export const useAttribute = () => {
const [attributes, setAttributes] = useState<Attribute[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -68,6 +69,15 @@ export const useAttribute = () => {
}
}, []);
const getAttributeById = useCallback(async (id: string): Promise<Attribute | undefined> => {
try {
return await attributeService.getById(id);
} catch (err) {
notify.error(err);
return undefined;
}
}, []);
return {
attributes,
loading,
@@ -76,5 +86,6 @@ export const useAttribute = () => {
createAttribute,
updateAttribute,
deleteAttribute,
getAttributeById,
};
};
@@ -9,7 +9,7 @@ import { DataTable } from "../../../components/customs/DataTable";
import { useAttribute } from "../hook/useAttribute";
import type { Attribute } from "../types/attribute.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { AttributeCard } from "../components/AttributeCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
@@ -183,28 +183,28 @@ export default function AttributeList() {
{/* Attribute Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<AttributeCard
<StatsCard
title="Total Attributes"
value={stats.total}
subtitle="configured"
color="purple"
icon={<Grid3x3 className="w-6 h-6" />}
/>
<AttributeCard
<StatsCard
title="Active Attributes"
value={stats.active}
subtitle={`${stats.total > 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of total`}
color="green"
icon={<CheckCircle2 className="w-6 h-6" />}
/>
<AttributeCard
<StatsCard
title="Variant Eligible"
value={stats.variantEligible}
subtitle="Used for SKU generation"
color="blue"
icon={<LayoutGrid className="w-6 h-6" />}
/>
<AttributeCard
<StatsCard
title="Most Used"
value={stats.mostUsed}
subtitle="10,089 usages"
+93 -71
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
@@ -14,14 +14,14 @@ import type { AttributeCreateRequest } from "../types/attribute.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STEPS = [
{ id: "general", label: "General Information", step: 1 },
{ id: "validation", label: "Validation Rules", step: 2 },
{ id: "behavior", label: "Behavior Settings", step: 3 },
{ id: "review", label: "Review Configuration",step: 4 },
{ id: "general", label: "General Information", step: 1 },
{ id: "validation", label: "Validation Rules", step: 2 },
{ id: "behavior", label: "Behavior Settings", step: 3 },
{ id: "review", label: "Review Configuration", step: 4 },
];
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
@@ -38,10 +38,12 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewAttribute() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = pathname.includes('/edit');
const [activeStep, setActiveStep] = useState("general");
const { createAttribute, updateAttribute, fetchAttributes, attributes } = useAttribute();
const { createAttribute, updateAttribute, fetchAttributes, getAttributeById } = useAttribute();
const formik = useFormik({
initialValues: {
@@ -98,6 +100,7 @@ export default function NewAttribute() {
if (values.description?.trim()) payload.description = values.description.trim();
try {
if (isEdit && id) {
delete payload.code;
await updateAttribute(id, payload);
} else {
await createAttribute(payload as AttributeCreateRequest);
@@ -124,39 +127,42 @@ export default function NewAttribute() {
useEffect(() => { fetchAttributes(); }, [fetchAttributes]);
// For edit/view: load attribute data directly by ID from the API
useEffect(() => {
if (isEdit && attributes.length > 0) {
const match = attributes.find(item => item.id === id);
if (match) {
formik.setValues({
code: match.code,
name: match.name,
description: (match as any).description || "",
dataType: match.type || "text",
status: match.status || "draft",
isUnique: match.isUnique ?? false,
minLength: (match as any).minLength ?? 0,
maxLength: (match as any).maxLength ?? 255,
regexPattern: (match as any).regexPattern || "",
defaultValue: (match as any).defaultValue || "",
isRequired: match.isRequired ?? false,
isVariantEligible: (match as any).isVariantEligible ?? false,
isSearchable: (match as any).isSearchable ?? false,
isFilterable: (match as any).isFilterable ?? false,
isLocalizable: match.isLocalizable ?? false,
isChannelSpecific: (match as any).isChannelSpecific ?? false,
helpText: (match as any).helpText || "",
placeholder: (match as any).placeholder || "",
sortable: (match as any).sortable ?? false,
visibleInGrid: (match as any).visibleInGrid ?? true,
visibleInProduct: (match as any).visibleInProduct ?? true,
apiVisible: (match as any).apiVisible ?? true,
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
});
}
if ((isEdit || isView) && id) {
getAttributeById(id).then((match) => {
if (match) {
formik.setValues({
code: match.code,
name: match.name,
description: (match as any).description || "",
dataType: match.type || "text",
status: match.status || "draft",
isUnique: match.isUnique ?? false,
minLength: (match as any).minLength ?? 0,
maxLength: (match as any).maxLength ?? 255,
regexPattern: (match as any).regexPattern || "",
defaultValue: (match as any).defaultValue || "",
isRequired: match.isRequired ?? false,
isVariantEligible: (match as any).isVariantEligible ?? false,
isSearchable: (match as any).isSearchable ?? false,
isFilterable: (match as any).isFilterable ?? false,
isLocalizable: match.isLocalizable ?? false,
isChannelSpecific: (match as any).isChannelSpecific ?? false,
helpText: (match as any).helpText || "",
placeholder: (match as any).placeholder || "",
sortable: (match as any).sortable ?? false,
visibleInGrid: (match as any).visibleInGrid ?? true,
visibleInProduct: (match as any).visibleInProduct ?? true,
apiVisible: (match as any).apiVisible ?? true,
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
});
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, attributes]);
}, [isEdit, isView, id]);
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
@@ -167,16 +173,23 @@ export default function NewAttribute() {
{/* Top Bar */}
<div className="shrink-0 px-6 pt-3">
<Breadcrumb
items={[{ label: "Home" }, { label: "Attributes", href: "/attributes" }, { label: isEdit ? "Edit Attribute" : "Create Attribute" }]}
items={[{ label: "Home" }, { label: "Attributes", href: "/attributes" }, { label: isView ? "View Attribute" : isEdit ? "Edit Attribute" : "Create Attribute" }]}
backTo="/attributes"
actions={
<>
<Button variant="outline" type="button" onClick={() => navigate("/attributes")}>Cancel</Button>
<Button variant="outline" type="button">Save as Draft</Button>
<Button variant="primary" type="submit" form="attribute-form" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Save Attribute"}
</Button>
</>
isView ? (
<>
<Button variant="outline" type="button" onClick={() => navigate("/attributes")}>Close</Button>
<Button variant="primary" type="button" onClick={() => navigate(`/attributes/${id}/edit`)}>Edit Attribute</Button>
</>
) : (
<>
<Button variant="outline" type="button" onClick={() => navigate("/attributes")}>Cancel</Button>
<Button variant="outline" type="button">Save as Draft</Button>
<Button variant="primary" type="submit" form="attribute-form" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Save Attribute"}
</Button>
</>
)
}
/>
</div>
@@ -205,19 +218,18 @@ export default function NewAttribute() {
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = s.step < activeIndex + 1;
const isLast = idx === STEPS.length - 1;
const isDone = s.step < activeIndex + 1;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -233,9 +245,8 @@ export default function NewAttribute() {
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
</button>
</div>
);
@@ -260,7 +271,7 @@ export default function NewAttribute() {
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
disabled={isEdit || isView}
placeholder="e.g., product_weight"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
@@ -273,6 +284,7 @@ export default function NewAttribute() {
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
disabled={isView}
placeholder="e.g., Product Weight"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
@@ -282,7 +294,7 @@ export default function NewAttribute() {
<div>
<label className={labelClass}>Data Type <span className="text-red-400">*</span></label>
<Select name="dataType" value={formik.values.dataType} onChange={formik.handleChange}>
<Select name="dataType" value={formik.values.dataType} onChange={formik.handleChange} disabled={isView}>
<option value="text">Text Short text input (single line)</option>
<option value="textarea">Text Long text input (multi line)</option>
<option value="number">Number</option>
@@ -302,6 +314,7 @@ export default function NewAttribute() {
checked={formik.values.status === "draft"}
onChange={formik.handleChange}
label="Draft"
disabled={isView}
/>
<Radio
name="status"
@@ -309,6 +322,7 @@ export default function NewAttribute() {
checked={formik.values.status === "active"}
onChange={formik.handleChange}
label="Active"
disabled={isView}
/>
<Radio
name="status"
@@ -316,6 +330,7 @@ export default function NewAttribute() {
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
label="Inactive"
disabled={isView}
/>
</RadioGroup>
</div>
@@ -327,6 +342,7 @@ export default function NewAttribute() {
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isView}
placeholder="Describe how this attribute should be used..."
rows={3}
style={{ minHeight: "80px" }}
@@ -340,6 +356,7 @@ export default function NewAttribute() {
name="helpText"
value={formik.values.helpText}
onChange={formik.handleChange}
disabled={isView}
placeholder="Help context for content managers..."
/>
</div>
@@ -349,6 +366,7 @@ export default function NewAttribute() {
name="placeholder"
value={formik.values.placeholder}
onChange={formik.handleChange}
disabled={isView}
placeholder="Input placeholder text..."
/>
</div>
@@ -368,6 +386,7 @@ export default function NewAttribute() {
name="isUnique"
checked={formik.values.isUnique}
onChange={formik.handleChange}
disabled={isView}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
/>
<div>
@@ -379,11 +398,11 @@ export default function NewAttribute() {
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Minimum Length</label>
<Input type="number" name="minLength" value={formik.values.minLength} onChange={formik.handleChange} min={0} />
<Input type="number" name="minLength" value={formik.values.minLength} onChange={formik.handleChange} min={0} disabled={isView} />
</div>
<div>
<label className={labelClass}>Maximum Length</label>
<Input type="number" name="maxLength" value={formik.values.maxLength} onChange={formik.handleChange} min={0} />
<Input type="number" name="maxLength" value={formik.values.maxLength} onChange={formik.handleChange} min={0} disabled={isView} />
</div>
</div>
@@ -393,6 +412,7 @@ export default function NewAttribute() {
name="regexPattern"
value={formik.values.regexPattern}
onChange={formik.handleChange}
disabled={isView}
placeholder="e.g., ^[A-Z]{3}-\d{4}$"
/>
</div>
@@ -403,6 +423,7 @@ export default function NewAttribute() {
name="defaultValue"
value={formik.values.defaultValue}
onChange={formik.handleChange}
disabled={isView}
placeholder="Optional default value"
/>
</div>
@@ -417,16 +438,16 @@ export default function NewAttribute() {
<div className="p-6">
<div className="grid grid-cols-2 gap-3">
{[
{ name: "isRequired", label: "Required", desc: "Must be filled for all products" },
{ name: "isVariantEligible", label: "Variant Eligible", desc: "Can be used in variant axes" },
{ name: "isSearchable", label: "Searchable", desc: "Indexed for search queries" },
{ name: "isFilterable", label: "Filterable", desc: "Available as product filter" },
{ name: "isLocalizable", label: "Localizable", desc: "Different value per locale" },
{ name: "isChannelSpecific", label: "Channel Specific", desc: "Different value per channel" },
{ name: "sortable", label: "Sortable", desc: "Enables listing sort actions" },
{ name: "visibleInGrid", label: "Visible in Grid", desc: "Shown in products data table" },
{ name: "visibleInProduct", label: "Visible in Product Edit", desc: "Rendered in specs editing form" },
{ name: "apiVisible", label: "API Visible", desc: "Exposed on public API responses" },
{ name: "isRequired", label: "Required", desc: "Must be filled for all products" },
{ name: "isVariantEligible", label: "Variant Eligible", desc: "Can be used in variant axes" },
{ name: "isSearchable", label: "Searchable", desc: "Indexed for search queries" },
{ name: "isFilterable", label: "Filterable", desc: "Available as product filter" },
{ name: "isLocalizable", label: "Localizable", desc: "Different value per locale" },
{ name: "isChannelSpecific", label: "Channel Specific", desc: "Different value per channel" },
{ name: "sortable", label: "Sortable", desc: "Enables listing sort actions" },
{ name: "visibleInGrid", label: "Visible in Grid", desc: "Shown in products data table" },
{ name: "visibleInProduct", label: "Visible in Product Edit", desc: "Rendered in specs editing form" },
{ name: "apiVisible", label: "API Visible", desc: "Exposed on public API responses" },
{ name: "isRequiredForCompleteness", label: "Required for Completeness", desc: "Mandatory check for 100% completeness" }
].map(({ name, label, desc }) => (
<label key={name} className="flex items-start gap-3 px-4 py-3.5 border border-gray-100 rounded-lg hover:border-primary/10 hover:bg-primary/5/20 transition-all cursor-pointer">
@@ -435,6 +456,7 @@ export default function NewAttribute() {
name={name}
checked={(formik.values as any)[name]}
onChange={formik.handleChange}
disabled={isView}
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
/>
<div>
@@ -10,6 +10,7 @@ export const AttributeRoutes = () => {
<Route path="groups" element={<AttributeGroupList />} />
<Route path="new" element={<NewAttribute />} />
<Route path=":id/edit" element={<NewAttribute />} />
<Route path=":id/view" element={<NewAttribute />} />
</Routes>
);
};
+19 -10
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useBrand } from "../hook/useBrand";
@@ -29,7 +30,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewBrand() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createBrand, updateBrand } = useBrand();
@@ -81,7 +84,7 @@ export default function NewBrand() {
};
useEffect(() => {
if (isEdit && id) {
if ((isEdit || isView) && id) {
brandService.getById(id).then((brand) => {
if (brand) {
setIsActive(brand.status === 'active');
@@ -96,23 +99,27 @@ export default function NewBrand() {
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
}, [isEdit, isView, id]);
return (
<PageWrapper>
<ProtectedRoute node="masters.brands">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Brands', href: '/brands' }, { label: isEdit ? 'Edit Brand' : 'Create Brand' }]}
items={[{ label: 'Home' }, { label: 'Brands', href: '/brands' }, { label: isView ? 'View Brand' : isEdit ? 'Edit Brand' : 'Create Brand' }]}
backTo="/brands"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/brands')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="brand-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Brand'}</Button>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/brands')} disabled={formik.isSubmitting}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && <Button variant="outline" size="md" type="button">Save as Draft</Button>}
{!isView && <Button variant="primary" size="md" type="submit" form="brand-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Brand'}</Button>}
</>
}
/>
<form id="brand-form" onSubmit={formik.handleSubmit} className="space-y-5">
<fieldset disabled={isView} className="contents">
{/* Basic Information */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Basic Information" subtitle="Enter the basic details for this brand" />
@@ -193,7 +200,9 @@ export default function NewBrand() {
</div>
</div>
</div>
</form>
</fieldset>
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -8,6 +8,7 @@ export const BrandRoutes = () => {
<Route index element={<BrandList />} />
<Route path="new" element={<NewBrandForm />} />
<Route path=":id/edit" element={<NewBrandForm />} />
<Route path=":id/view" element={<NewBrandForm />} />
</Routes>
);
};
@@ -1,61 +0,0 @@
import type { ReactNode } from "react";
/**
* CategoryCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface CategoryCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
export function CategoryCard({ title, value, subtitle, icon, color }: CategoryCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">{title}</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">{value}</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">{subtitle}</div>
</div>
);
}
@@ -3,6 +3,7 @@ import { categoryService } from '../services/category.service';
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
import { notify } from '../../../services/toast';
export const useCategory = () => {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -9,7 +9,7 @@ import { DataTable } from "../../../components/customs/DataTable";
import { useCategory } from "../hook/useCategory";
import type { Category } from "../types/category.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { CategoryCard } from "../components/CategoryCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { CategoryTaxonomyTree } from "../components/CategoryTaxonomyTree";
@@ -116,10 +116,10 @@ export default function CategoryList() {
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<CategoryCard title="Total Categories" value={stats.total} subtitle="configured" color="purple" icon={<Grid3x3 className="w-6 h-6" />} />
<CategoryCard title="Active Categories" value={stats.active} subtitle="currently active" color="green" icon={<Layers className="w-6 h-6" />} />
<CategoryCard title="Total Products" value={stats.products.toLocaleString()} subtitle="in this category tree" color="blue" icon={<Package className="w-6 h-6" />} />
<CategoryCard title="Product Families" value={stats.families} subtitle="linked families" color="orange" icon={<LayoutGrid className="w-6 h-6" />} />
<StatsCard title="Total Categories" value={stats.total} subtitle="configured" color="purple" icon={<Grid3x3 className="w-6 h-6" />} />
<StatsCard title="Active Categories" value={stats.active} subtitle="currently active" color="green" icon={<Layers className="w-6 h-6" />} />
<StatsCard title="Total Products" value={stats.products.toLocaleString()} subtitle="in this category tree" color="blue" icon={<Package className="w-6 h-6" />} />
<StatsCard title="Product Families" value={stats.families} subtitle="linked families" color="orange" icon={<LayoutGrid className="w-6 h-6" />} />
</div>
{/* View toggle + section header */}
@@ -1,99 +0,0 @@
import type { ReactNode } from "react";
/**
* ProductCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ChannelTypeCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function ChannelTypeCard({ title, value, subtitle, icon, color }: ChannelTypeCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
@@ -26,7 +26,7 @@ import { StatusBadge } from "../../../components/customs/StatusBadge";
import { useChannelType } from "../hook/useChannelType";
import type { ChannelType } from "../types/channel-types.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { ChannelTypeCard } from "../components/ChannelTypeCard";
import { StatsCard } from "../../../components/customs/StatsCard";
const ICON_MAP: Record<string, any> = {
ShoppingCart,
@@ -149,28 +149,28 @@ export default function ChannelTypeList() {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<ChannelTypeCard
<StatsCard
title="Total Types"
value={items.length}
subtitle="All channel types"
color="purple"
icon={<Layers2 className="w-5 h-5" />}
/>
<ChannelTypeCard
<StatsCard
title="Active Types"
value={totalActive}
subtitle="Currently active"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<ChannelTypeCard
<StatsCard
title="Inactive Types"
value={totalInactive}
subtitle="Not in use"
color="red"
icon={<XCircle className="w-5 h-5" />}
/>
<ChannelTypeCard
<StatsCard
title="Total Channels"
value={totalChannels}
subtitle="Linked channels"
@@ -1,98 +0,0 @@
import type { ReactNode } from "react";
/**
* ChannelStatsCard a premium styled card component mirroring the visual design of ProductCard.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ChannelStatsCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function ChannelStatsCard({ title, value, subtitle, icon, color }: ChannelStatsCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+9 -6
View File
@@ -2,11 +2,12 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { ChannelStatsCard } from "../components/ChannelStatsCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { useChannel } from "../hook/useChannel";
import type { Channel } from "../types/channels.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
@@ -108,7 +109,8 @@ export default function ChannelList() {
return (
<PageWrapper>
<ProtectedRoute node="settings.integrations">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Channel Registry" }]}
actions={
@@ -126,28 +128,28 @@ export default function ChannelList() {
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* Stats Cards */}
<ChannelStatsCard
<StatsCard
title="Total Channels"
value="9"
subtitle="Active and inactive"
color="purple"
icon={<Radio className="w-5 h-5" />}
/>
<ChannelStatsCard
<StatsCard
title="Active Channels"
value="8"
subtitle="Serving traffic"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<ChannelStatsCard
<StatsCard
title="Families Assigned"
value="183"
subtitle="Across all storefronts"
color="blue"
icon={<Layers className="w-5 h-5" />}
/>
<ChannelStatsCard
<StatsCard
title="Products Published"
value="69,905"
subtitle="Synced items"
@@ -181,5 +183,6 @@ export default function ChannelList() {
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
}
+23 -12
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
@@ -13,6 +13,7 @@ import { useChannel } from "../hook/useChannel";
import { channelsService } from "../services/channels.service";
import type { ChannelCreateRequest } from "../types/channels.types";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
@@ -59,7 +60,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewChannel() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createItem, updateItem } = useChannel();
const [activeStep, setActiveStep] = useState("basic");
const [selectedType, setSelectedType] = useState<string | null>(null);
@@ -91,7 +94,7 @@ export default function NewChannel() {
});
useEffect(() => {
if (isEdit && id) {
if ((isEdit || isView) && id) {
channelsService.getById(id).then((channel) => {
if (channel) {
formik.setValues({
@@ -106,7 +109,7 @@ export default function NewChannel() {
}
});
}
}, [id, isEdit]);
}, [id, isEdit, isView]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
@@ -118,16 +121,21 @@ export default function NewChannel() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<PageWrapper>
<ProtectedRoute node="settings.integrations">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Channels", href: "/channels" }, { label: isEdit ? "Edit Channel" : "Create Channel" }]}
items={[{ label: "Home" }, { label: "Channels", href: "/channels" }, { label: isView ? "View Channel" : isEdit ? "Edit Channel" : "Create Channel" }]}
backTo="/channels"
actions={
<>
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-primary hover:bg-primary-hover text-white">
{isEdit ? "Update Channel" : "Create Channel"}
<Button variant="outline" type="button" onClick={() => navigate("..")}>
{isView ? "Back" : "Cancel"}
</Button>
{!isView && (
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-primary hover:bg-primary-hover text-white">
{isEdit ? "Update Channel" : "Create Channel"}
</Button>
)}
</>
}
/>
@@ -194,7 +202,8 @@ export default function NewChannel() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
<fieldset disabled={isView} className="contents">
<div className="overflow-y-auto">
{/* Step 1 — Basic Information */}
{activeStep === "basic" && (
@@ -389,8 +398,9 @@ export default function NewChannel() {
</div>
)}
</div>
</div>
</fieldset>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
@@ -404,5 +414,6 @@ export default function NewChannel() {
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -7,5 +7,6 @@ export const ChannelRoutes = () => (
<Route index element={<ChannelList />} />
<Route path="new" element={<NewChannel />} />
<Route path=":id/edit" element={<NewChannel />} />
<Route path=":id/view" element={<NewChannel />} />
</Routes>
);
+5 -5
View File
@@ -9,7 +9,7 @@ import { DataTable } from "../../../components/customs/DataTable";
import { useFamily } from "../hook/useFamily";
import type { Family } from "../types/family.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { FamilyCard } from "../components/FamilyCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
@@ -146,28 +146,28 @@ export default function FamilyList() {
{/* Family Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<FamilyCard
<StatsCard
title="Total Families"
value={stats.total}
subtitle="in your catalog"
color="purple"
icon={<LayoutGrid className="w-6 h-6" />}
/>
<FamilyCard
<StatsCard
title="Published"
value={stats.published}
subtitle="Active families"
color="green"
icon={<BookCheck className="w-6 h-6" />}
/>
<FamilyCard
<StatsCard
title="Total Products"
value={stats.totalProducts.toLocaleString()}
subtitle="across all families"
color="blue"
icon={<Box className="w-6 h-6" />}
/>
<FamilyCard
<StatsCard
title="Avg Completeness"
value={`${stats.avgCompleteness}%`}
subtitle="Overall"
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ export const FamilyRoutes = () => {
<Route path="/" element={<FamilyList />} />
<Route path="new" element={<NewFamily />} />
<Route path=":id/edit" element={<NewFamily />} />
<Route path=":id/view" element={<NewFamily />} />
</Routes>
);
};
@@ -23,6 +23,7 @@ export interface Family {
attribute_set_id?: string;
allowedBrands?: any[];
allowedUnits?: any[];
attributeSet?: any;
}
export type FamilyCreateRequest = Omit<Family, 'id' | 'lastUpdated' | 'createdBy'>;
@@ -1,59 +0,0 @@
import type { ReactNode } from "react";
/* ── Single Stat Card ── */
interface IntegrationCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-primary/20", iconText: "text-primary", bgGradient: "bg-primary/5/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
export function IntegrationCard({ title, value, subtitle, icon, color }: IntegrationCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">{title}</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">{value}</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">{subtitle}</div>
</div>
);
}
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
@@ -21,7 +22,7 @@ import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { IntegrationCard } from "../components/IntegrationStatsCards";
import { StatsCard } from "../../../components/customs/StatsCard";
// Import Tab Components
import PublishingRulesList from "../components/PublishingRulesTab";
@@ -170,7 +171,8 @@ export default function IntegrationList() {
});
return (
<PageWrapper>
<ProtectedRoute node="settings.integrations">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Integration Hub" }]}
actions={
@@ -188,28 +190,28 @@ export default function IntegrationList() {
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<IntegrationCard
<StatsCard
title="Total Integrations"
value={items.length}
subtitle="All integrations"
icon={<Plug className="w-5 h-5" />}
color="purple"
/>
<IntegrationCard
<StatsCard
title="Connected Systems"
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
subtitle="Healthy"
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<IntegrationCard
<StatsCard
title="Failed Sync Jobs"
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
subtitle="Need attention"
icon={<AlertCircle className="w-5 h-5" />}
color="red"
/>
<IntegrationCard
<StatsCard
title="Last Synchronised"
value="14:32"
subtitle="Today"
@@ -307,5 +309,6 @@ export default function IntegrationList() {
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
@@ -12,6 +12,7 @@ import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
import { integrationsService } from "../services/integrations.service";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STEPS = [
@@ -33,9 +34,10 @@ const INTEGRATION_TYPES = [
{ id: "custom_api", label: "Custom API", sub: "Any REST or GraphQL endpoint", icon: Code2 },
];
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency }: {
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency, disabled }: {
name: string; channel: string; channelName: string; type: string;
environment: string; syncDirection: string; syncFrequency: string;
disabled?: boolean;
}) {
return (
<div className="w-60 shrink-0">
@@ -73,7 +75,7 @@ function IntegrationSummary({ name, channel, channelName, type, environment, syn
</div>
</div>
<div className="mt-5 pt-5 border-t border-gray-100">
<button type="button" className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors">
<button type="button" disabled={disabled} className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<Zap className="w-4 h-4" />
Test Connection
</button>
@@ -99,7 +101,9 @@ const integrationSchema = Yup.object({
export default function NewIntegration() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const [activeStep, setActiveStep] = useState('general');
const [showToken, setShowToken] = useState(false);
const [loadingItem, setLoadingItem] = useState(false);
@@ -177,7 +181,7 @@ export default function NewIntegration() {
});
useEffect(() => {
if (isEdit && id) {
if ((isEdit || isView) && id) {
setLoadingItem(true);
integrationsService.getById(id).then((item) => {
if (item) {
@@ -224,7 +228,7 @@ export default function NewIntegration() {
setLoadingItem(false);
}).catch(() => setLoadingItem(false));
}
}, [id, isEdit]);
}, [id, isEdit, isView]);
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
@@ -269,21 +273,28 @@ export default function NewIntegration() {
}
return (
<PageWrapper>
<ProtectedRoute node="settings.integrations">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Integration Hub', href: '/integrations' }, { label: isEdit ? 'Edit Integration' : 'Create Integration' }]}
items={[{ label: 'Home' }, { label: 'Integration Hub', href: '/integrations' }, { label: isView ? 'View Integration' : isEdit ? 'Edit Integration' : 'Create Integration' }]}
backTo="/integrations"
actions={
<>
<button
type="button"
className="flex items-center gap-2 py-2 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
</button>
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-primary hover:bg-primary-hover text-white">{isEdit ? "Update Integration" : "Save Integration"}</Button>
{!isView && (
<button
type="button"
className="flex items-center gap-2 py-2 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
</button>
)}
<Button variant="outline" type="button" onClick={() => navigate('..')}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && (
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-primary hover:bg-primary-hover text-white">{isEdit ? "Update Integration" : "Save Integration"}</Button>
)}
</>
}
/>
@@ -326,7 +337,8 @@ export default function NewIntegration() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
<fieldset disabled={isView} className="contents">
<div className="overflow-y-auto">
{activeStep === 'general' && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="General Information" subtitle="Name, channel, environment and status" />
@@ -966,7 +978,8 @@ export default function NewIntegration() {
)}
</div>
</fieldset>
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors">Back</button>
@@ -979,6 +992,7 @@ export default function NewIntegration() {
{/* Right: Integration Summary */}
<IntegrationSummary
disabled={isView}
name={formik.values.name}
channel={formik.values.channel}
channelName={selectedChanName}
@@ -989,5 +1003,6 @@ export default function NewIntegration() {
/>
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -7,5 +7,6 @@ export const IntegrationRoutes = () => (
<Route index element={<IntegrationList />} />
<Route path="new" element={<NewIntegration />} />
<Route path=":id/edit" element={<NewIntegration />} />
<Route path=":id/view" element={<NewIntegration />} />
</Routes>
);
@@ -1,25 +0,0 @@
// src/features/notifications/components/NotificationEmptyState.tsx
import { Bell } from "lucide-react";
interface NotificationEmptyStateProps {
hasFilters: boolean;
}
export function NotificationEmptyState({ hasFilters }: NotificationEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-surface-muted flex items-center justify-center mb-4">
<Bell className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1">
{hasFilters ? "No matching notifications" : "You're all caught up"}
</h3>
<p className="text-sm text-muted-foreground max-w-xs">
{hasFilters
? "Try adjusting your search or filters to find what you're looking for."
: "New activity across your catalog, workflows, and team will appear here."}
</p>
</div>
);
}
@@ -1,6 +1,6 @@
// src/features/notifications/components/NotificationPageHeader.tsx
import { Bell, CheckCheck } from "lucide-react";
import { CheckCheck } from "lucide-react";
interface NotificationPageHeaderProps {
unreadCount: number;
@@ -15,21 +15,6 @@ export function NotificationPageHeader({
}: NotificationPageHeaderProps) {
return (
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<Bell className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground leading-tight">
Notifications
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount !== 1 ? "s" : ""}`
: "All caught up — no unread notifications"}
</p>
</div>
</div>
{unreadCount > 0 && (
<button
@@ -1,38 +0,0 @@
// src/features/notifications/components/NotificationSearchBar.tsx
import { Search, X } from "lucide-react";
interface NotificationSearchBarProps {
value: string;
onChange: (value: string) => void;
}
export function NotificationSearchBar({ value, onChange }: NotificationSearchBarProps) {
return (
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search notifications…"
className={[
"w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-border bg-surface",
"placeholder-muted-foreground text-foreground",
"focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary",
"transition-colors",
].join(" ")}
aria-label="Search notifications"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -1,211 +0,0 @@
// src/features/notifications/data/notifications.mock.ts
//
// Static fixture data — mock-phase only. Replaced by real API in Step 5.
// Types are imported from the canonical types file; no local re-declarations.
import type { NotificationItem } from "../types/notification.types";
const now = new Date();
const mins = (n: number) => new Date(now.getTime() - n * 60_000).toISOString();
const hours = (n: number) => new Date(now.getTime() - n * 3_600_000).toISOString();
const days = (n: number) => new Date(now.getTime() - n * 86_400_000).toISOString();
export const MOCK_NOTIFICATIONS: NotificationItem[] = [
// ── Today ──────────────────────────────────────────────────────────────────
{
id: "n-001",
variant: "product",
action: "created",
title: "New product created",
description: "\"Wireless Noise-Cancelling Headphones\" was added to the catalog.",
entity: "Wireless Noise-Cancelling Headphones",
entityId: "prod-881",
actor: "Sarah Chen",
isRead: false,
createdAt: mins(8),
link: "/products/prod-881",
},
{
id: "n-002",
variant: "category",
action: "updated",
title: "Category updated",
description: "\"Electronics > Audio\" category tree was restructured.",
entity: "Electronics > Audio",
entityId: "cat-44",
actor: "James Okafor",
isRead: false,
createdAt: mins(22),
link: "/categories/cat-44",
},
{
id: "n-003",
variant: "workflow",
action: "approved",
title: "Product approved",
description: "\"Smart Watch Series 9\" passed the QA review stage.",
entity: "Smart Watch Series 9",
entityId: "prod-774",
actor: "Priya Nair",
isRead: false,
createdAt: hours(1),
link: "/products/prod-774",
},
{
id: "n-004",
variant: "brand",
action: "created",
title: "New brand added",
description: "Brand \"Luminary\" was registered in the master data.",
entity: "Luminary",
entityId: "brand-29",
actor: "Carlos Mendez",
isRead: true,
createdAt: hours(3),
link: "/brands/brand-29",
},
{
id: "n-005",
variant: "product",
action: "deleted",
title: "Product deleted",
description: "\"Legacy USB Hub v1\" was permanently removed from the catalog.",
entity: "Legacy USB Hub v1",
entityId: "prod-102",
actor: "Sarah Chen",
isRead: true,
createdAt: hours(5),
},
// ── Yesterday ──────────────────────────────────────────────────────────────
{
id: "n-006",
variant: "attribute",
action: "created",
title: "Attribute created",
description: "New attribute \"Battery Capacity (mAh)\" was added to the registry.",
entity: "Battery Capacity (mAh)",
entityId: "attr-67",
actor: "Mei Lin",
isRead: true,
createdAt: days(1),
link: "/attributes/attr-67",
},
{
id: "n-007",
variant: "family",
action: "updated",
title: "Product family updated",
description: "\"Consumer Electronics\" family had 3 attributes reassigned.",
entity: "Consumer Electronics",
entityId: "fam-12",
actor: "James Okafor",
isRead: false,
createdAt: days(1),
link: "/families/fam-12",
},
{
id: "n-008",
variant: "role",
action: "updated",
title: "Role permissions changed",
description: "\"Content Editor\" role was granted export permissions on Products.",
entity: "Content Editor",
entityId: "role-5",
actor: "Admin",
isRead: true,
createdAt: days(1),
link: "/users/roles/role-5",
},
{
id: "n-009",
variant: "product",
action: "published",
title: "Product published",
description: "\"4K OLED Monitor 27\"\" is now live on the Shopify channel.",
entity: "4K OLED Monitor 27\"",
entityId: "prod-553",
actor: "Priya Nair",
isRead: true,
createdAt: days(1),
link: "/products/prod-553",
},
// ── Last 7 Days ────────────────────────────────────────────────────────────
{
id: "n-010",
variant: "store",
action: "created",
title: "Store created",
description: "New store \"APAC Marketplace\" was configured and activated.",
entity: "APAC Marketplace",
entityId: "store-8",
actor: "Carlos Mendez",
isRead: true,
createdAt: days(3),
link: "/channels/store-8",
},
{
id: "n-011",
variant: "category",
action: "deleted",
title: "Category deleted",
description: "\"Discontinued > Legacy\" category was removed from the taxonomy.",
entity: "Discontinued > Legacy",
entityId: "cat-91",
actor: "Mei Lin",
isRead: true,
createdAt: days(4),
},
{
id: "n-012",
variant: "brand",
action: "updated",
title: "Brand updated",
description: "\"TechCore\" brand logo and description were refreshed.",
entity: "TechCore",
entityId: "brand-7",
actor: "Sarah Chen",
isRead: true,
createdAt: days(5),
link: "/brands/brand-7",
},
{
id: "n-013",
variant: "workflow",
action: "rejected",
title: "Product rejected",
description: "\"Portable Bluetooth Speaker\" failed the compliance check.",
entity: "Portable Bluetooth Speaker",
entityId: "prod-340",
actor: "James Okafor",
isRead: true,
createdAt: days(6),
link: "/products/prod-340",
},
// ── Older ──────────────────────────────────────────────────────────────────
{
id: "n-014",
variant: "attribute",
action: "updated",
title: "Attribute updated",
description: "\"Color\" attribute options were expanded with 12 new values.",
entity: "Color",
entityId: "attr-3",
actor: "Priya Nair",
isRead: true,
createdAt: days(10),
link: "/attributes/attr-3",
},
{
id: "n-015",
variant: "family",
action: "created",
title: "Product family created",
description: "\"Home Appliances\" family was created with 24 base attributes.",
entity: "Home Appliances",
entityId: "fam-18",
actor: "Carlos Mendez",
isRead: true,
createdAt: days(14),
link: "/families/fam-18",
},
];
+1 -1
View File
@@ -5,6 +5,6 @@
export * from "./types/notification.types";
export * from "./constants/notification.constants";
export * from "./hooks/useNotifications";
export * from "./hook/useNotifications";
export { NotificationRoutes } from "./routes/notifications.routes";
export { notificationService } from "./services/notification.service";
@@ -1,14 +1,16 @@
// src/features/notifications/pages/NotificationsPage.tsx
import { Bell } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { NotificationPageHeader } from "../components/NotificationPageHeader";
import { NotificationSearchBar } from "../components/NotificationSearchBar";
import { NotificationFilterBar } from "../components/NotificationFilterBar";
import { NotificationTimeline } from "../components/NotificationTimeline";
import { NotificationSkeletonList } from "../components/NotificationSkeleton";
import { NotificationEmptyState } from "../components/NotificationEmptyState";
import { useNotifications } from "../hooks/useNotifications";
import { useNotifications } from "../hook/useNotifications";
import { Pagination } from "../../../components/customs/Pagination";
import { SearchBar } from "../../../components/customs/SearchBar";
import { EmptyState } from "../../../components/customs/EmptyState";
export default function NotificationsPage() {
const {
@@ -26,6 +28,9 @@ export default function NotificationsPage() {
setReadFilter,
markRead,
markAllRead,
page,
total,
setPage,
} = useNotifications();
return (
@@ -40,7 +45,12 @@ export default function NotificationsPage() {
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap mb-6">
<NotificationSearchBar value={search} onChange={setSearch} />
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search notifications..."
className="flex-1"
/>
<NotificationFilterBar
variantFilter={variantFilter}
readFilter={readFilter}
@@ -61,9 +71,32 @@ export default function NotificationsPage() {
{isLoading ? (
<NotificationSkeletonList count={6} />
) : items.length === 0 ? (
<NotificationEmptyState hasFilters={hasFilters} />
<EmptyState
icon={<Bell className="w-8 h-8 text-muted-foreground" />}
title={hasFilters ? "No matching notifications" : "You're all caught up"}
description={
hasFilters
? "Try adjusting your search or filters to find what you're looking for."
: "New activity across your catalog, workflows, and team will appear here."
}
/>
) : (
<NotificationTimeline notifications={items} onMarkRead={markRead} />
<div className="flex flex-col h-full">
<div className="flex-1 pb-4">
<NotificationTimeline notifications={items} onMarkRead={markRead} />
</div>
<div className="sticky bottom-0 bg-white border-t border-gray-100 -mx-8 px-8 pb-4 pt-4 mt-auto z-10">
<div className="max-w-[760px]">
<Pagination
page={page}
pageSize={20}
total={total}
onPageChange={setPage}
resultLabel="notifications"
/>
</div>
</div>
</div>
)}
</PageWrapper>
);
@@ -1,99 +0,0 @@
import type { ReactNode } from "react";
/**
* ProductCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ProductCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function ProductCard({ title, value, subtitle, icon, color }: ProductCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+184 -107
View File
@@ -17,17 +17,18 @@ import { StageTimeline } from '../../workflow/components/StageTimeline';
import { toast } from 'react-toastify';
import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, Copy, Image as ImageIcon, Info, FolderTree, AlertCircle, RefreshCw, Check, Plus } from 'lucide-react';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { FamilyCard } from '../../../components/customs/FamilyCard';
const TABS = [
{ id: 'general', label: 'General', icon: Box, step: 1 },
{ id: 'general', label: 'General', icon: Box, step: 1 },
{ id: 'attributes', label: 'Attributes', icon: LayoutGrid, step: 2 },
{ id: 'variants', label: 'Variants', icon: Tags, step: 3 },
{ id: 'assets', label: 'Assets', icon: ImageIcon, step: 4 },
{ id: 'variants', label: 'Variants', icon: Tags, step: 3 },
{ id: 'assets', label: 'Assets', icon: ImageIcon, step: 4 },
{ id: 'categories', label: 'Categories', icon: FolderTree, step: 5 },
{ id: 'channels', label: 'Channels', icon: Globe, step: 6 },
{ id: 'review', label: 'Review', icon: Eye, step: 7 },
{ id: 'channels', label: 'Channels', icon: Globe, step: 6 },
{ id: 'review', label: 'Review', icon: Eye, step: 7 },
];
export default function NewProduct() {
@@ -66,7 +67,6 @@ export default function NewProduct() {
const [creationMethod, setCreationMethod] = useState<'family' | 'clone'>('family');
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('general');
const [productLoading, setProductLoading] = useState(false);
// 1. Hook configuration for loading all PIM metadata dynamically
const {
@@ -204,7 +204,6 @@ export default function NewProduct() {
useEffect(() => {
if (isEdit && id) {
const loadProduct = async () => {
setProductLoading(true);
try {
const productData: any = await productService.getById(id);
if (productData) {
@@ -231,8 +230,8 @@ export default function NewProduct() {
gtin: productData.gtin || '',
upc: productData.upc || '',
ean: productData.ean || '',
brand: productData.brand_id || productData.brand || '',
unit: productData.unit_id || productData.unit || '',
brand: productData.brand_id || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || '',
unit: productData.unit_id || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || '',
country: productData.country || '',
hsn: productData.hsn || '',
type: productData.type || 'simple',
@@ -250,7 +249,6 @@ export default function NewProduct() {
} catch (err: any) {
toast.error(err.message || 'Failed to load product details');
} finally {
setProductLoading(false);
}
};
loadProduct();
@@ -297,37 +295,12 @@ export default function NewProduct() {
}
};
if (productLoading || configLoading) {
return (
<PageWrapper>
<div className="flex flex-col items-center justify-center min-h-[400px]">
<RefreshCw className="w-8 h-8 text-primary animate-spin mb-4" />
<p className="text-sm text-gray-500">Loading Product Configuration...</p>
</div>
</PageWrapper>
);
}
if (configError) {
return (
<PageWrapper>
<div className="p-8 text-center bg-red-50 border border-red-200 rounded-xl max-w-xl mx-auto mt-12 shadow-xs">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="font-semibold text-gray-900 mb-2">Failed to load family configuration</h3>
<p className="text-sm text-gray-600 mb-4">{configError}</p>
<button
onClick={configRetry}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" /> Retry Loading
</button>
</div>
</PageWrapper>
);
}
const inputClass = "w-full border border-gray-200 focus:ring-primary rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
return (
<PageWrapper>
<ProtectedRoute node="products.items">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Products', href: '/products' }, { label: isEdit ? 'Edit Product' : 'Create Product' }]}
backTo="/products"
@@ -374,9 +347,10 @@ export default function NewProduct() {
<div className="max-w-[1400px] w-full mx-auto">
{step === 1 && (
<div className="max-w-5xl">
<h2 className="text-sm font-semibold text-gray-900 mb-3">Choose Creation Method</h2>
<div className="flex gap-4 mb-8">
<button
<button
onClick={() => setCreationMethod('family')}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${creationMethod === 'family' ? 'border-primary bg-primary/5/50' : 'border-gray-200 bg-white hover:border-primary/20'}`}
>
@@ -388,8 +362,8 @@ export default function NewProduct() {
<div className="text-xs text-gray-500">Start with family template and variant structure</div>
</div>
</button>
<button
<button
onClick={() => setCreationMethod('clone')}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${creationMethod === 'clone' ? 'border-primary bg-primary/5/50' : 'border-gray-200 bg-white hover:border-primary/20'}`}
>
@@ -442,9 +416,9 @@ export default function NewProduct() {
<Copy className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h3 className="font-medium text-gray-900 mb-1">Search for a product to clone</h3>
<p className="text-sm text-gray-500 mb-4">You can copy all attributes and settings from an existing product.</p>
<input
type="text"
placeholder="Enter product name or SKU..."
<input
type="text"
placeholder="Enter product name or SKU..."
className="max-w-md w-full border border-gray-200 focus:ring-primary rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white"
/>
</div>
@@ -475,19 +449,18 @@ export default function NewProduct() {
{TABS.map((tab, idx) => {
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const isActive = activeTab === tab.id;
const isDone = tab.step < activeIndex + 1;
const isLast = idx === TABS.length - 1;
const isDone = tab.step < activeIndex + 1;
const isLast = idx === TABS.length - 1;
return (
<div key={tab.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveTab(tab.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-emerald-500' :
'bg-white border-2 border-gray-200 hover:border-primary/30'
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-emerald-500' :
'bg-white border-2 border-gray-200 hover:border-primary/30'
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -503,9 +476,8 @@ export default function NewProduct() {
onClick={() => setActiveTab(tab.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? 'text-primary-dark' : isDone ? 'text-gray-600' : 'text-gray-400 hover:text-gray-600'
}`}>{tab.label}</span>
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary-dark' : isDone ? 'text-gray-600' : 'text-gray-400 hover:text-gray-600'
}`}>{tab.label}</span>
</button>
</div>
);
@@ -528,10 +500,10 @@ export default function NewProduct() {
</div>
<div className="space-y-1.5 text-[11px]">
{[
{ label: 'Variants', value: '0' },
{ label: 'Assets', value: '0' },
{ label: 'Variants', value: '0' },
{ label: 'Assets', value: '0' },
{ label: 'Categories', value: '1' },
{ label: 'Channels', value: '0' },
{ label: 'Channels', value: '0' },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-gray-500">{label}</span>
@@ -558,10 +530,10 @@ export default function NewProduct() {
{workflowStages.find((s: any) => s.code === formik.values.metadata?.currentStage)?.name || formik.values.metadata?.currentStage || 'Draft'}
</span>
</div>
{(() => {
const currentStageCode = formik.values.metadata?.currentStage || 'draft';
const sorted = [...workflowStages].sort((a,b) => Number(a.order || 0) - Number(b.order || 0));
const sorted = [...workflowStages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
const currentIdx = sorted.findIndex((s: any) => s.code === currentStageCode);
if (currentIdx !== -1 && currentIdx < sorted.length - 1) {
const nextStage = sorted[currentIdx + 1];
@@ -607,55 +579,159 @@ export default function NewProduct() {
</div>
</div>
)}
<div className="overflow-y-auto pr-1 flex-1">
{activeTab === 'general' && (
<div className="space-y-8">
{/* General Section */}
<ProductGeneralSection
nameValue={formik.values.name}
codeValue={formik.values.code}
skuValue={formik.values.sku}
barcodeValue={formik.values.barcode}
gtinValue={formik.values.gtin}
upcValue={formik.values.upc}
eanValue={formik.values.ean}
statusValue={formik.values.status}
typeValue={formik.values.type}
shortDescValue={formik.values.shortDesc}
onFieldChange={(field, val) => formik.setFieldValue(field, val)}
nameError={formik.errors.name}
nameTouched={formik.touched.name}
skuError={formik.errors.sku}
skuTouched={formik.touched.sku}
/>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-8">
{/* Basic Information */}
<div>
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-gray-900">Basic Information</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="col-span-2">
<label className={labelClass}>Product Name *</label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
placeholder="Enter product name"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Product Code *</label>
<input name="code" value={formik.values.code} readOnly className={`${inputClass} bg-gray-50 text-gray-500`} />
</div>
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
<Radio
name="status"
value="draft"
checked={formik.values.status === 'draft'}
onChange={formik.handleChange}
label="Draft"
/>
<Radio
name="status"
value="active"
checked={formik.values.status === 'active'}
onChange={formik.handleChange}
label="Active"
/>
</RadioGroup>
</div>
</div>
</div>
{/* Category Selection */}
<ProductCategorySection
categories={categories}
value={formik.values.category}
onChange={(val) => formik.setFieldValue('category', val)}
error={formik.errors.category}
touched={formik.touched.category}
/>
{/* Identification */}
<div>
<div className="flex items-center gap-2 mb-4">
<Tags className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-gray-900">Identification & Barcodes</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>SKU</label>
<input name="sku" value={formik.values.sku} onChange={formik.handleChange} placeholder="Stock Keeping Unit" className={inputClass} />
</div>
<div>
<label className={labelClass}>Barcode</label>
<input name="barcode" value={formik.values.barcode} onChange={formik.handleChange} placeholder="Barcode" className={inputClass} />
</div>
<div>
<label className={labelClass}>GTIN</label>
<input name="gtin" value={formik.values.gtin} onChange={formik.handleChange} placeholder="Global Trade Item Number" className={inputClass} />
</div>
<div>
<label className={labelClass}>UPC</label>
<input name="upc" value={formik.values.upc} onChange={formik.handleChange} placeholder="Universal Product Code" className={inputClass} />
</div>
<div className="col-span-2">
<label className={labelClass}>EAN</label>
<input name="ean" value={formik.values.ean} onChange={formik.handleChange} placeholder="European Article Number" className={inputClass} />
</div>
</div>
</div>
{/* Brand & Unit Classification */}
<ProductBrandSection
allowedBrands={activeAllowedBrandsList}
allowedUnits={activeAllowedUnitsList}
brandValue={formik.values.brand}
unitValue={formik.values.unit}
countryValue={formik.values.country}
hsnValue={formik.values.hsn}
onBrandChange={(val) => formik.setFieldValue('brand', val)}
onUnitChange={(val) => formik.setFieldValue('unit', val)}
onCountryChange={(val) => formik.setFieldValue('country', val)}
onHsnChange={(val) => formik.setFieldValue('hsn', val)}
brandError={formik.errors.brand}
brandTouched={formik.touched.brand}
unitError={formik.errors.unit}
unitTouched={formik.touched.unit}
/>
{/* Classification */}
<div>
<div className="flex items-center gap-2 mb-4">
<LayoutGrid className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-gray-900">Classification</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Brand</label>
<Select
name="brand"
value={formik.values.brand}
onChange={formik.handleChange}
>
<option value="">Select brand</option>
{selectedFamilyObj?.allowedBrands?.map((b: any) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</Select>
</div>
<div>
<label className={labelClass}>Unit</label>
<Select
name="unit"
value={formik.values.unit}
onChange={formik.handleChange}
>
<option value="">Select unit</option>
{selectedFamilyObj?.allowedUnits?.map((u: any) => (
<option key={u.id} value={u.id}>
{u.name} {u.symbol ? `(${u.symbol})` : `(${u.code})`}
</option>
))}
</Select>
</div>
<div>
<label className={labelClass}>Country of Origin</label>
<input placeholder="e.g., USA, China, Germany" className={inputClass} />
</div>
<div>
<label className={labelClass}>HSN Code</label>
<input placeholder="Harmonized System Code" className={inputClass} />
</div>
</div>
</div>
{/* Product Type */}
<div>
<h3 className="font-semibold text-gray-900 mb-4">Product Type</h3>
<div className="flex gap-4">
<label className="flex items-center gap-3 p-4 border border-primary/20 bg-primary/5 rounded-xl cursor-pointer flex-1">
<Radio
name="type"
value="simple"
checked={formik.values.type === 'simple'}
onChange={formik.handleChange}
/>
<div>
<div className="font-medium text-gray-900 text-sm">Simple Product</div>
<div className="text-xs text-gray-500">Single product without variations</div>
</div>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 bg-white hover:border-primary/20 rounded-xl cursor-pointer flex-1">
<Radio
name="type"
value="variant"
checked={formik.values.type === 'variant'}
onChange={formik.handleChange}
/>
<div>
<div className="font-medium text-gray-900 text-sm">Variant Product</div>
<div className="text-xs text-gray-500">Product with multiple variations (size, color, etc.)</div>
</div>
</label>
</div>
</div>
</div>
)}
@@ -723,7 +799,7 @@ export default function NewProduct() {
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-gray-900 mb-1">Category Assignment</h3>
<p className="text-sm text-gray-500 mb-6">Assign this product to multiple category trees for different channels</p>
<div className="space-y-4">
<div className="border border-gray-200 rounded-xl p-4 flex items-center justify-between bg-white">
<div>
@@ -748,7 +824,7 @@ export default function NewProduct() {
</div>
<span className="px-2 py-0.5 text-xs font-medium text-primary-dark bg-primary/5 border border-primary/20 rounded-md">Inherited from Family</span>
</div>
<div className="space-y-4">
{(family?.channels || []).map((ch: any, i: number) => (
<div key={i} className="border border-gray-200 rounded-xl p-4 bg-white">
@@ -870,5 +946,6 @@ export default function NewProduct() {
)}
</div>
</PageWrapper>
</ProtectedRoute>
);
}
+6 -6
View File
@@ -7,7 +7,7 @@ import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
import { Button, ExportButton, ImportButton } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { ProductCard } from "../components/ProductCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { useProduct } from "../hook/useProduct";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { DataTable, type DataTableColumn } from "../../../components/customs/DataTable";
@@ -97,7 +97,7 @@ export default function ProductList() {
key: "category",
label: "Category",
sortable: true,
render: val => <span className="text-sm text-gray-700">{val || "—"}</span>,
render: val => <span className="text-sm text-gray-700">{val && typeof val === "object" ? (val as any).name : (val || "—")}</span>,
},
{
key: "price",
@@ -154,10 +154,10 @@ export default function ProductList() {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<ProductCard title="Published" value={stats.published} subtitle="Ready to sell" color="green" icon={<CheckCircle2 className="w-6 h-6" />} />
<ProductCard title="Pending Review" value={stats.pending} subtitle="Needs attention" color="orange" icon={<Clock className="w-6 h-6" />} />
<ProductCard title="Draft" value={stats.draft} subtitle="In your catalog" color="slate" icon={<Package className="w-6 h-6" />} />
<ProductCard title="Avg Completeness" value={`${stats.avgCompleteness}%`} subtitle="Overall" color="red" icon={<AlertCircle className="w-6 h-6" />} />
<StatsCard title="Published" value={stats.published} subtitle="Ready to sell" color="green" icon={<CheckCircle2 className="w-6 h-6" />} />
<StatsCard title="Pending Review" value={stats.pending} subtitle="Needs attention" color="orange" icon={<Clock className="w-6 h-6" />} />
<StatsCard title="Draft" value={stats.draft} subtitle="In your catalog" color="slate" icon={<Package className="w-6 h-6" />} />
<StatsCard title="Avg Completeness" value={`${stats.avgCompleteness}%`} subtitle="Overall" color="red" icon={<AlertCircle className="w-6 h-6" />} />
</div>
{/* Loading State */}
@@ -22,6 +22,22 @@ export interface Product {
name: string;
variantAxes?: Attribute[];
};
code?: string;
category_id?: string;
barcode?: string;
gtin?: string;
upc?: string;
ean?: string;
brand_id?: string;
brand?: string;
unit_id?: string;
unit?: string;
country?: string;
hsn?: string;
type?: string;
shortDesc?: string;
metadata?: any;
family_id?: string;
}
export type ProductCreateRequest = Omit<Product, 'id' | 'updatedAt' | 'updatedBy' | 'completeness' | 'channels'>;
+97 -85
View File
@@ -14,6 +14,7 @@ import { getSupportedActions, PERMISSION_LABELS } from "../types/role.types";
import { tenantService } from "../../tenants/services/tenant.service";
import type { Tenant } from "../../tenants/types/tenant.types";
import { notify } from "../../../services/toast";
import { Checkbox } from "../../../components/customs/CheckBox"; // Adjust path if needed
const roleSchema = Yup.object().shape({
role_name: Yup.string().required("Role name is required"),
@@ -22,7 +23,7 @@ const roleSchema = Yup.object().shape({
});
const STEPS = [
{ id: "details", label: "Role Details", step: 1 },
{ id: "details", label: "Role Details", step: 1 },
{ id: "permissions", label: "Permissions Matrix", step: 2 },
];
@@ -55,9 +56,6 @@ export default function NewRoleForm() {
const [fetching, setFetching] = useState(isEdit);
const [fetchError, setFetchError] = useState<string | null>(null);
// ─── Stable ref for formik.setValues ────────────────────────────────────────
// Declared as a plain MutableRefObject<any> to avoid referencing `formik`
// before it is defined. Kept in sync on every render below.
const setValuesRef = useRef<((values: any) => void) | null>(null);
const formik = useFormik({
@@ -69,7 +67,6 @@ export default function NewRoleForm() {
p.can_view || p.can_create || p.can_edit ||
p.can_delete || p.can_alter || p.can_export || p.can_import
);
// Normalize tenant_id to null for both create and update
const payload = { ...values, tenant_id: values.tenant_id || null };
try {
if (isEdit && id) {
@@ -79,18 +76,15 @@ export default function NewRoleForm() {
}
navigate("/users/roles");
} catch {
// errors surfaced by hook via toast
// errors handled by hook
} finally {
setSubmitting(false);
}
},
});
// Keep ref current on every render — no stale closure in loadEditData
setValuesRef.current = formik.setValues;
// ─── loadEditData ────────────────────────────────────────────────────────────
// Depends only on [id, navigate]. Uses setValuesRef so formik is never a dep.
const loadEditData = useCallback(
async (allNodes: PermissionNode[]) => {
if (!id) return;
@@ -99,7 +93,6 @@ export default function NewRoleForm() {
try {
const role = await roleService.getById(id);
// Guard: null, undefined, or missing id means the role doesn't exist
if (!role || !role.id) {
const msg = "Role not found or has been deleted.";
setFetchError(msg);
@@ -108,42 +101,39 @@ export default function NewRoleForm() {
return;
}
// Sync Formik values via ref — no stale closure, no extra re-renders
setValuesRef.current!({
role_name: role.role_name || "",
role_name: role.role_name || "",
description: role.description || "",
tenant_id: role.tenant_id || "",
tenant_id: role.tenant_id || "",
});
// Build existing permissions map from junction data
const existingPerms: Record<string, RolePermission> = {};
if (Array.isArray(role.permissions)) {
role.permissions.forEach((p: any) => {
const junction = p.RolePermission ?? p;
const nodeId = String(p.id ?? p.node_id);
const nodeId = String(p.id ?? p.node_id);
existingPerms[nodeId] = {
node_id: nodeId,
can_view: Boolean(junction.can_view),
node_id: nodeId,
can_view: Boolean(junction.can_view),
can_create: Boolean(junction.can_create),
can_edit: Boolean(junction.can_edit),
can_edit: Boolean(junction.can_edit),
can_delete: Boolean(junction.can_delete),
can_alter: Boolean(junction.can_alter),
can_alter: Boolean(junction.can_alter),
can_export: Boolean(junction.can_export),
can_import: Boolean(junction.can_import),
};
});
}
// Merge: every node gets a row; existing perms pre-fill, rest default false
const merged: Record<string, RolePermission> = {};
allNodes.forEach((node) => {
merged[node.id] = existingPerms[node.id] ?? {
node_id: node.id,
can_view: false,
node_id: node.id,
can_view: false,
can_create: false,
can_edit: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_alter: false,
can_export: false,
can_import: false,
};
@@ -151,13 +141,11 @@ export default function NewRoleForm() {
setPermissions(merged);
} catch (err: any) {
const status = err?.response?.status;
const msg =
status === 404
? "Role not found. It may have been deleted."
: err?.response?.data?.message || "Failed to load role data";
const msg = status === 404
? "Role not found. It may have been deleted."
: err?.response?.data?.message || "Failed to load role data";
setFetchError(msg);
notify.error(msg);
// Redirect on 404 — role no longer exists
if (status === 404) {
setTimeout(() => navigate("/users/roles"), 2000);
}
@@ -168,24 +156,16 @@ export default function NewRoleForm() {
[id, navigate]
);
// ─── Stop spinner if fetchNodes fails in edit mode ───────────────────────────
// Runs whenever nodesError changes. If nodes failed, surface the error and
// stop the full-page spinner so the error UI + Retry button become visible.
// Also clears fetchError when nodesError is cleared (i.e. after a successful
// retry of fetchNodes), so the form renders instead of staying on error UI.
useEffect(() => {
if (!isEdit) return;
if (nodesError) {
setFetching(false);
setFetchError(nodesError);
} else {
// nodesError cleared → nodes loaded successfully; clear any prior node error
// Only clear if the current fetchError was the nodes error (not a role error)
setFetchError((prev) => (prev === nodesError ? null : prev));
}
}, [isEdit, nodesError]);
// ─── Initial data load ───────────────────────────────────────────────────────
useEffect(() => {
Promise.all([
fetchNodes(),
@@ -193,14 +173,9 @@ export default function NewRoleForm() {
]);
}, [fetchNodes]);
// ─── Seed permissions once nodes are available ───────────────────────────────
// In edit mode: loadEditData is called here (not in handleRetry) to avoid
// double-calling. handleRetry only re-fetches nodes; this effect handles the rest.
useEffect(() => {
if (nodes.length === 0) return;
if (isEdit) {
// Only run when fetching is still true (initial load or after a retry that
// cleared fetchError). Prevents re-running on unrelated nodes reference changes.
if (fetching) {
loadEditData(nodes);
}
@@ -208,12 +183,12 @@ export default function NewRoleForm() {
const initialPerms: Record<string, RolePermission> = {};
nodes.forEach((node) => {
initialPerms[node.id] = {
node_id: node.id,
can_view: false,
node_id: node.id,
can_view: false,
can_create: false,
can_edit: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_alter: false,
can_export: false,
can_import: false,
};
@@ -222,7 +197,50 @@ export default function NewRoleForm() {
}
}, [nodes, isEdit, loadEditData, fetching]);
// ─── Permission toggle ───────────────────────────────────────────────────────
// ─── New Helpers for Select All ─────────────────────────────────────
const isAllSelected = useCallback((nodeId: string): boolean => {
const perm = permissions[nodeId];
if (!perm) return false;
const node = nodes.find((n) => n.id === nodeId);
if (!node) return false;
const supportedActions = getSupportedActions(node);
if (supportedActions.length === 0) return false;
return supportedActions.every((key) => perm[key as keyof RolePermission] === true);
}, [permissions, nodes]);
const toggleAllForNode = useCallback((nodeId: string, checked: boolean) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return;
const supportedActions = getSupportedActions(node);
setPermissions((prev) => {
const currentPerm = prev[nodeId] || {
node_id: nodeId,
can_view: false,
can_create: false,
can_edit: false,
can_delete: false,
can_alter: false,
can_export: false,
can_import: false,
};
const updatedPerm: RolePermission = { ...currentPerm };
supportedActions.forEach((key) => {
(updatedPerm as any)[key] = checked;
});
return {
...prev,
[nodeId]: updatedPerm,
};
});
}, [nodes]);
const togglePermission = (nodeId: string, action: keyof RolePermission) => {
setPermissions((prev) => ({
...prev,
@@ -230,9 +248,6 @@ export default function NewRoleForm() {
}));
};
// ─── Step validation gate ────────────────────────────────────────────────────
// Validates Step 1 fields and returns true if navigation to a later step is
// allowed. Touches role_name so the inline error becomes visible on failure.
const validateStep1 = async (): Promise<boolean> => {
const errors = await formik.validateForm();
if (errors.role_name) {
@@ -242,11 +257,9 @@ export default function NewRoleForm() {
return true;
};
// Navigate to a specific step, enforcing Step 1 validation when moving forward
const navigateToStep = async (targetId: string) => {
const targetIndex = STEPS.findIndex((s) => s.id === targetId);
const currentIndex = STEPS.findIndex((s) => s.id === activeStep);
// Moving forward from Step 1 requires validation
if (currentIndex === 0 && targetIndex > 0) {
const valid = await validateStep1();
if (!valid) return;
@@ -254,22 +267,13 @@ export default function NewRoleForm() {
setActiveStep(targetId);
};
// ─── Retry ───────────────────────────────────────────────────────────────────
// If nodes failed: re-fetch nodes. The useEffect above will call loadEditData
// once nodes arrive (fetching will be true at that point).
// If nodes are fine but role fetch failed: call loadEditData directly.
const handleRetry = useCallback(async () => {
setFetchError(null);
if (!isEdit) return;
if (nodesError || nodes.length === 0) {
// Nodes failed — set fetching so the spinner shows, then re-fetch nodes.
// The nodes useEffect will trigger loadEditData once nodes load.
setFetching(true);
await fetchNodes();
// If fetchNodes failed again, the nodesError useEffect will stop the spinner.
// If it succeeded, nodes useEffect fires loadEditData (fetching is still true).
} else {
// Nodes are fine — only the role fetch failed; retry it directly.
await loadEditData(nodes);
}
}, [isEdit, nodesError, nodes, fetchNodes, loadEditData]);
@@ -310,7 +314,6 @@ export default function NewRoleForm() {
}
/>
{/* Full-page loading — edit mode only */}
{fetching && (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
@@ -320,7 +323,6 @@ export default function NewRoleForm() {
</div>
)}
{/* Full-page error — edit mode only */}
{!fetching && fetchError && (
<div className="flex flex-col items-center justify-center h-64 gap-3">
<p className="text-sm text-danger">{fetchError}</p>
@@ -336,8 +338,7 @@ export default function NewRoleForm() {
{!fetching && !fetchError && (
<form id="role-form" onSubmit={formik.handleSubmit} className="flex gap-5">
{/* ── Timeline Sidebar ── */}
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-surface border border-primary/10 rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-primary/5 bg-primary/5">
<p className="text-[11px] font-semibold text-primary uppercase tracking-widest">Configuration</p>
@@ -357,8 +358,8 @@ export default function NewRoleForm() {
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
@@ -367,14 +368,17 @@ export default function NewRoleForm() {
onClick={() => navigateToStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-success" :
"bg-surface border-2 border-border hover:border-primary/30"
isDone ? "bg-success" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
: <span className={`text-[9px] font-bold ${isActive ? "text-white" : "text-subtle-foreground"}`}>{s.step}</span>
}
{isDone ? (
<Check className="w-3 h-3 text-white" />
) : (
<span className={`text-[9px] font-bold ${isActive ? "text-white" : "text-subtle-foreground"}`}>
{s.step}
</span>
)}
</button>
{!isLast && (
<div
@@ -390,8 +394,8 @@ export default function NewRoleForm() {
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" :
isDone ? "text-muted-foreground" :
"text-subtle-foreground hover:text-muted-foreground"
isDone ? "text-muted-foreground" :
"text-subtle-foreground hover:text-muted-foreground"
}`}>
{s.label}
</span>
@@ -402,11 +406,10 @@ export default function NewRoleForm() {
</nav>
</aside>
{/* ── Main Content ── */}
{/* Main Content */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
{/* Step 1 — Role Details */}
{/* Step 1 */}
{activeStep === "details" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Role Details" subtitle="Define the role name, tenant and description" />
@@ -458,7 +461,7 @@ export default function NewRoleForm() {
</div>
)}
{/* Step 2 — Permissions Matrix */}
{/* Step 2 — Permissions Matrix with Select All */}
{activeStep === "permissions" && (
<div
className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden flex flex-col"
@@ -496,14 +499,26 @@ export default function NewRoleForm() {
{nodes.map((node) => {
const perm = permissions[node.id];
const supportedActions = getSupportedActions(node);
return (
<div
key={node.id}
className="flex items-center gap-4 px-6 py-4 hover:bg-primary-light transition-colors"
className="flex items-center gap-4 px-6 py-4 hover:bg-primary-ghost transition-colors"
>
{/* Select All Checkbox for this row */}
<div className="shrink-0">
<Checkbox
checked={isAllSelected(node.id)}
onCheckedChange={(checked) => toggleAllForNode(node.id, !!checked)}
/>
</div>
{/* Node Name */}
<div className="w-40 flex items-center gap-2.5 shrink-0">
<span className="text-sm font-semibold text-foreground">{node.node_name}</span>
</div>
{/* Permission Buttons */}
<div className="flex flex-wrap gap-2">
{supportedActions.map((key) => {
const checked = (perm?.[key] as boolean) || false;
@@ -536,10 +551,9 @@ export default function NewRoleForm() {
)}
</div>
)}
</div>
{/* Bottom navigation */}
{/* Bottom Navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<button
@@ -561,11 +575,9 @@ export default function NewRoleForm() {
)}
</div>
</div>
</form>
)}
</PageWrapper>
</ProtectedRoute>
);
}
}
+26 -11
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { Input } from "../../../components/customs/Input";
@@ -11,6 +12,7 @@ import { useUnit } from "../hook/useUnit";
import { unitService } from "../services/unit.service";
import { unitSchema } from "../validation/unit.schema";
import type { UnitCreateRequest } from "../types/unit.types";
import { generateCodeFromName } from "../../../utils/validators";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
@@ -31,7 +33,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewUnit() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createUnit, updateUnit } = useUnit();
@@ -76,7 +80,7 @@ export default function NewUnit() {
});
useEffect(() => {
if (isEdit && id) {
if ((isEdit || isView) && id) {
unitService.getById(id).then((unit) => {
if (unit) {
setIsActive(unit.status === 'active');
@@ -93,23 +97,27 @@ export default function NewUnit() {
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
}, [isEdit, isView, id]);
return (
<PageWrapper>
<ProtectedRoute node="masters.units">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Units', href: '/units' }, { label: isEdit ? 'Edit Unit' : 'Create Unit' }]}
items={[{ label: 'Home' }, { label: 'Units', href: '/units' }, { label: isView ? 'View Unit' : isEdit ? 'Edit Unit' : 'Create Unit' }]}
backTo="/units"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="unit-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Unit'}</Button>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && <Button variant="outline" size="md" type="button">Save as Draft</Button>}
{!isView && <Button variant="primary" size="md" type="submit" form="unit-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Unit'}</Button>}
</>
}
/>
<form id="unit-form" onSubmit={formik.handleSubmit} className="space-y-5">
<fieldset disabled={isView} className="contents">
{/* Basic Information */}
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Basic Information" subtitle="Enter the basic details for this unit" />
@@ -119,7 +127,12 @@ export default function NewUnit() {
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
if (!isEdit) {
formik.setFieldValue("code", generateCodeFromName(e.target.value));
}
}}
onBlur={formik.handleBlur}
placeholder="e.g., Kilogram"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
@@ -232,7 +245,9 @@ export default function NewUnit() {
</div>
</div>
</div>
</form>
</fieldset>
</form>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -8,6 +8,7 @@ export const UnitRoutes = () => {
<Route index element={<UnitList />} />
<Route path="new" element={<NewUnit />} />
<Route path=":id/edit" element={<NewUnit />} />
<Route path=":id/view" element={<NewUnit />} />
</Routes>
);
};
@@ -1,98 +0,0 @@
import type { ReactNode } from "react";
/**
* UserStatsCard a premium styled card component mirroring the visual design of ProductCard.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface UserStatsCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function UserStatsCard({ title, value, subtitle, icon, color }: UserStatsCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+18 -5
View File
@@ -7,7 +7,7 @@ import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { UserStatsCard } from "../components/UserStatsCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { usersService } from "../services/users.service";
import { rolesService } from "../services/roles.service";
import { tenantService } from "../../tenants/services/tenant.service";
@@ -226,6 +226,19 @@ export default function UserList() {
);
}
},
{
key: "tenant",
label: "TENANT",
render: (_: any, row: any) => {
const userTenant = tenants.find(t => t.id === row.tenant_id);
const name = userTenant ? userTenant.tenant_name : "Platform Core";
return (
<span className="text-sm text-gray-600">
{name}
</span>
);
}
},
{
key: "status",
label: "STATUS",
@@ -269,28 +282,28 @@ export default function UserList() {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<UserStatsCard
<StatsCard
title="Total Members"
value={users.length}
subtitle={`${users.filter(u => u.status).length} active`}
color="slate"
icon={<Users className="w-5 h-5" />}
/>
<UserStatsCard
<StatsCard
title="Roles Defined"
value={roles.length}
subtitle="permission groups"
color="purple"
icon={<Shield className="w-5 h-5" />}
/>
<UserStatsCard
<StatsCard
title="2FA Enabled"
value={0}
subtitle="0% adoption"
color="green"
icon={<Lock className="w-5 h-5" />}
/>
<UserStatsCard
<StatsCard
title="Pending Invites"
value={users.filter(u => !u.status).length}
subtitle="awaiting signup"
@@ -1,100 +0,0 @@
import type { ReactNode } from "react";
/**
* VariantCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface VariantCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function VariantCard({ title, value, subtitle, icon, color }: VariantCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+26 -15
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useFormik } from 'formik';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
import { Button } from '../../../components/customs/Button';
import { Radio, RadioGroup } from '../../../components/customs/Radio';
import { Select } from '../../../components/customs/Select';
@@ -12,7 +13,7 @@ import { variantSchema } from '../validation/variant.schema';
import type { VariantStatus } from '../types/variant.types';
import { Save, Check, Loader2, AlertCircle } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { toast } from 'react-toastify';
import { notify } from '../../../services/toast';
const STEPS = [
{ id: 'basic', label: 'Basic Information', step: 1 },
@@ -40,7 +41,9 @@ function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
export default function NewVariant() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { pathname } = useLocation();
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { products, fetchProducts, loading: productsLoading } = useProduct();
const [parentProductDetail, setParentProductDetail] = useState<any>(null);
@@ -85,15 +88,15 @@ export default function NewVariant() {
try {
if (isEdit && id) {
await variantService.update(id, payload);
toast.success('Variant updated successfully!');
notify.success('Variant updated successfully!');
} else {
await variantService.create(payload);
toast.success('Variant created successfully!');
notify.success('Variant created successfully!');
}
navigate('/variants');
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Validation error';
toast.error(msg);
notify.error(msg);
} finally {
setSubmitting(false);
}
@@ -130,7 +133,7 @@ export default function NewVariant() {
}
})
.catch((err) => {
toast.error(err.message || 'Failed to fetch variant details');
notify.error(err);
})
.finally(() => {
setVariantLoading(false);
@@ -179,16 +182,21 @@ export default function NewVariant() {
const isLoading = productsLoading || variantLoading || parentLoading;
return (
<PageWrapper>
<ProtectedRoute node="products.variants">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Variants', href: '/variants' }, { label: isEdit ? 'Edit Variant' : 'Create Variant' }]}
items={[{ label: 'Home' }, { label: 'Variants', href: '/variants' }, { label: isView ? 'View Variant' : isEdit ? 'Edit Variant' : 'Create Variant' }]}
backTo="/variants"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting || isLoading}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="variant-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting} disabled={isLoading}>
{isEdit ? 'Update Variant' : 'Create Variant'}
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting || isLoading}>
{isView ? 'Back' : 'Cancel'}
</Button>
{!isView && (
<Button variant="primary" size="md" type="submit" form="variant-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting} disabled={isLoading}>
{isEdit ? 'Update Variant' : 'Create Variant'}
</Button>
)}
</>
}
/>
@@ -265,7 +273,8 @@ export default function NewVariant() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto min-h-[420px]">
<fieldset disabled={isView} className="contents">
<div className="overflow-y-auto min-h-[420px]">
{/* Step 1 — Basic Information */}
{activeStep === 'basic' && (
@@ -568,8 +577,9 @@ export default function NewVariant() {
</div>
)}
</div>
</div>
</fieldset>
{/* Bottom navigation */}
<div className="shrink-0 pt-4 flex justify-end gap-2 border-t border-primary/5 mt-4">
{activeIndex > 0 && (
@@ -583,5 +593,6 @@ export default function NewVariant() {
</form>
</PageWrapper>
</ProtectedRoute>
);
}
+5 -5
View File
@@ -9,7 +9,7 @@ import { useNavigate } from "react-router-dom";
import { Can } from "../../../components/customs/Can";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { VariantCard } from "../components/VariantStatsCards";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function VariantList() {
@@ -101,7 +101,7 @@ export default function VariantList() {
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<VariantCard
<StatsCard
title="Total Variants"
value={total}
subtitle="All variants"
@@ -109,7 +109,7 @@ export default function VariantList() {
icon={<Package className="w-5 h-5" />}
/>
<VariantCard
<StatsCard
title="Published"
value={publishedCount}
subtitle="Live & active"
@@ -117,7 +117,7 @@ export default function VariantList() {
icon={<CheckCircle className="w-5 h-5" />}
/>
<VariantCard
<StatsCard
title="Drafts"
value={draftCount}
subtitle="In progress"
@@ -125,7 +125,7 @@ export default function VariantList() {
icon={<Edit3 className="w-5 h-5" />}
/>
<VariantCard
<StatsCard
title="Disabled"
value={disabledCount}
subtitle="Not available"
@@ -8,6 +8,7 @@ export const VariantRoutes = () => {
<Route index element={<VariantList />} />
<Route path="new" element={<NewVariant />} />
<Route path=":id/edit" element={<NewVariant />} />
<Route path=":id/view" element={<NewVariant />} />
</Routes>
);
};
@@ -1,7 +1,7 @@
import { Download, Filter, Activity, Users, Clock, CheckCircle } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { WorkflowStatsCard } from "./WorkflowStatsCard";
import { StatsCard } from "../../../components/customs/StatsCard";
export const AuditUsageTab = () => {
const auditLogs = [
@@ -28,10 +28,10 @@ export const AuditUsageTab = () => {
return (
<div className="space-y-6 pb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<WorkflowStatsCard title="Items Processed" value="1,245" subtitle="All time" icon={<CheckCircle className="w-5 h-5" />} color="green" />
<WorkflowStatsCard title="Active Users" value="34" subtitle="In last 30 days" icon={<Users className="w-5 h-5" />} color="blue" />
<WorkflowStatsCard title="Avg. Time to Complete" value="1.2d" subtitle="-0.3d vs last month" icon={<Clock className="w-5 h-5" />} color="purple" />
<WorkflowStatsCard title="SLA Compliance" value="98%" subtitle="Excellent" icon={<Activity className="w-5 h-5" />} color="green" />
<StatsCard title="Items Processed" value="1,245" subtitle="All time" icon={<CheckCircle className="w-5 h-5" />} color="green" />
<StatsCard title="Active Users" value="34" subtitle="In last 30 days" icon={<Users className="w-5 h-5" />} color="blue" />
<StatsCard title="Avg. Time to Complete" value="1.2d" subtitle="-0.3d vs last month" icon={<Clock className="w-5 h-5" />} color="purple" />
<StatsCard title="SLA Compliance" value="98%" subtitle="Excellent" icon={<Activity className="w-5 h-5" />} color="green" />
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
@@ -1,14 +1,18 @@
import { useFormikContext } from "formik";
import { useParams } from "react-router-dom";
import { Input } from "../../../components/customs/Input";
import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { Label } from "../../../components/customs/Label";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { generateCodeFromName } from "../../../utils/validators";
const errorClass = "text-xs text-red-500 mt-1";
export const GeneralInfoTab = () => {
const formik = useFormikContext<any>();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
return (
<div className="space-y-6 pb-6">
@@ -29,7 +33,12 @@ export const GeneralInfoTab = () => {
id="name"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onChange={(e) => {
formik.handleChange(e);
if (!isEdit) {
formik.setFieldValue("code", generateCodeFromName(e.target.value));
}
}}
onBlur={formik.handleBlur}
placeholder="e.g. Electronics Approval Workflow"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
@@ -47,6 +56,7 @@ export const GeneralInfoTab = () => {
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. electronics_approval"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
@@ -1,98 +0,0 @@
import type { ReactNode } from "react";
/**
* WorkflowStatsCard a premium styled card component mirroring the visual design of ProductCard.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface WorkflowStatsCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-primary/20",
iconText: "text-primary",
bgGradient: "bg-primary/5/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function WorkflowStatsCard({ title, value, subtitle, icon, color }: WorkflowStatsCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+47 -54
View File
@@ -13,8 +13,10 @@ import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { StageTimeline } from "../components/StageTimeline";
import { toast } from "react-toastify";
import { Plus, Trash2, Edit2, ArrowUp, ArrowDown, Check, Layers, AlertCircle, Save } from "lucide-react";
import { Plus, Trash2, Edit2, ArrowUp, ArrowDown, Check, Layers, AlertCircle, Save } from "lucide-react";
import { workflowSchema } from "../validation/workflow.schema";
import type { WorkflowStage } from "../types/workflow.types";
import { notify } from "../../../services/toast";
const STEPS = [
{ step: 1, label: "Basic Details" },
@@ -60,7 +62,7 @@ export default function NewWorkflow() {
code: "",
description: "",
category: "Standard Approval",
status: "active" as 'active' | 'draft' | 'inactive',
status: "active" as 'active' | 'draft' | 'inactive' | 'archived',
stages: DEFAULT_STAGES as WorkflowStage[]
},
validationSchema: workflowSchema,
@@ -69,15 +71,16 @@ export default function NewWorkflow() {
try {
if (isEdit && id) {
await workflowService.update(id, values);
toast.success("Workflow updated successfully!");
notify.success("Workflow updated successfully!");
} else {
await workflowService.create(values);
toast.success("Workflow created successfully!");
notify.success("Workflow created successfully!");
}
navigate("/workflow");
} catch (err: any) {
const msg = err.response?.data?.message || err.message || "Failed to save workflow";
toast.error(msg);
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } }; message?: string };
const msg = error.response?.data?.message || error.message || "Failed to save workflow";
notify.error(msg);
} finally {
setLoading(false);
}
@@ -112,8 +115,9 @@ export default function NewWorkflow() {
stages: Array.isArray(data.stages) ? data.stages : []
});
}
} catch (err: any) {
toast.error(err.message || "Failed to load workflow details");
} catch (err: unknown) {
const error = err as { message?: string };
toast.error(error.message || "Failed to load workflow details");
} finally {
setLoading(false);
}
@@ -139,7 +143,7 @@ export default function NewWorkflow() {
const nextOrder = formik.values.stages.length + 1;
const stagesCopy = [...formik.values.stages, { ...newStage, code: slugifiedCode, order: nextOrder }];
formik.setFieldValue("stages", stagesCopy);
// Reset Add Form
setNewStage({ name: "", code: "", order: 1, color: "slate", description: "", actions: [] });
setShowAddSection(false);
@@ -147,13 +151,6 @@ export default function NewWorkflow() {
};
/*
const handleUpdateStage = (idx: number, updated: WorkflowStage) => {
const stagesCopy = [...formik.values.stages];
stagesCopy[idx] = updated;
formik.setFieldValue("stages", stagesCopy);
setEditingStageIdx(null);
toast.success("Stage updated!");
};
*/
const handleDeleteStage = (idx: number) => {
@@ -247,16 +244,14 @@ export default function NewWorkflow() {
key={s.step}
onClick={() => step > s.step && setStep(s.step)}
disabled={step < s.step}
className={`w-full flex items-center gap-3 text-left transition-all ${
isActive ? "text-primary font-bold" :
isCompleted ? "text-emerald-600 hover:text-emerald-700 cursor-pointer" : "text-gray-400 cursor-not-allowed"
}`}
className={`w-full flex items-center gap-3 text-left transition-all ${isActive ? "text-primary font-bold" :
isCompleted ? "text-emerald-600 hover:text-emerald-700 cursor-pointer" : "text-gray-400 cursor-not-allowed"
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 border ${
isActive ? "bg-primary border-primary text-white" :
isCompleted ? "bg-emerald-500 border-emerald-500 text-white" : "border-gray-200 bg-gray-50"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 border ${isActive ? "bg-primary border-primary text-white" :
isCompleted ? "bg-emerald-500 border-emerald-500 text-white" : "border-gray-200 bg-gray-50"
}`}
>
{isCompleted ? <Check className="w-3.5 h-3.5" /> : s.step}
</div>
@@ -271,7 +266,7 @@ export default function NewWorkflow() {
<div className="flex-1">
<FormikProvider value={formik}>
<form id="workflow-wizard-form" onSubmit={formik.handleSubmit}>
{/* STEP 1: Basic Information */}
{step === 1 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-xs overflow-hidden">
@@ -332,14 +327,13 @@ export default function NewWorkflow() {
<div className="space-y-1.5">
<Label>Workflow Status</Label>
<div className="flex gap-4">
{["active", "draft", "inactive"].map((statusOption) => (
{["active", "draft", "inactive", "archived"].map((statusOption) => (
<label
key={statusOption}
className={`flex items-center gap-3 p-3.5 rounded-lg border cursor-pointer flex-1 transition-all ${
formik.values.status === statusOption
className={`flex items-center gap-3 p-3.5 rounded-lg border cursor-pointer flex-1 transition-all ${formik.values.status === statusOption
? "border-primary/30 bg-primary/5 shadow-2xs"
: "border-gray-200 hover:border-primary/10 bg-white"
}`}
}`}
>
<input
type="radio"
@@ -440,18 +434,17 @@ export default function NewWorkflow() {
key={c}
type="button"
onClick={() => setNewStage(prev => ({ ...prev, color: c }))}
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 uppercase text-[9px] font-black tracking-widest ${
newStage.color === c ? "border-primary ring-2 ring-primary/20 scale-110" : "border-gray-200"
}`}
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 uppercase text-[9px] font-black tracking-widest ${newStage.color === c ? "border-primary ring-2 ring-primary/20 scale-110" : "border-gray-200"
}`}
style={{
backgroundColor: c === 'slate' ? '#64748b' :
c === 'blue' ? '#3b82f6' :
c === 'yellow' ? '#f59e0b' :
c === 'orange' ? '#f97316' :
c === 'emerald' ? '#10b981' :
c === 'green' ? '#22c55e' :
c === 'indigo' ? '#6366f1' :
c === 'purple' ? '#a855f7' : '#ef4444',
c === 'blue' ? '#3b82f6' :
c === 'yellow' ? '#f59e0b' :
c === 'orange' ? '#f97316' :
c === 'emerald' ? '#10b981' :
c === 'green' ? '#22c55e' :
c === 'indigo' ? '#6366f1' :
c === 'purple' ? '#a855f7' : '#ef4444',
color: '#ffffff'
}}
>
@@ -530,13 +523,13 @@ export default function NewWorkflow() {
className={`w-5 h-5 rounded-full border ${stage.color === c ? "ring-2 ring-primary/40" : ""}`}
style={{
backgroundColor: c === 'slate' ? '#64748b' :
c === 'blue' ? '#3b82f6' :
c === 'yellow' ? '#f59e0b' :
c === 'orange' ? '#f97316' :
c === 'emerald' ? '#10b981' :
c === 'green' ? '#22c55e' :
c === 'indigo' ? '#6366f1' :
c === 'purple' ? '#a855f7' : '#ef4444'
c === 'blue' ? '#3b82f6' :
c === 'yellow' ? '#f59e0b' :
c === 'orange' ? '#f97316' :
c === 'emerald' ? '#10b981' :
c === 'green' ? '#22c55e' :
c === 'indigo' ? '#6366f1' :
c === 'purple' ? '#a855f7' : '#ef4444'
}}
/>
))}
@@ -579,13 +572,13 @@ export default function NewWorkflow() {
<span className={`w-2.5 h-2.5 rounded-full`}
style={{
backgroundColor: stage.color === 'slate' ? '#64748b' :
stage.color === 'blue' ? '#3b82f6' :
stage.color === 'yellow' ? '#f59e0b' :
stage.color === 'orange' ? '#f97316' :
stage.color === 'emerald' ? '#10b981' :
stage.color === 'green' ? '#22c55e' :
stage.color === 'indigo' ? '#6366f1' :
stage.color === 'purple' ? '#a855f7' : '#ef4444'
stage.color === 'blue' ? '#3b82f6' :
stage.color === 'yellow' ? '#f59e0b' :
stage.color === 'orange' ? '#f97316' :
stage.color === 'emerald' ? '#10b981' :
stage.color === 'green' ? '#22c55e' :
stage.color === 'indigo' ? '#6366f1' :
stage.color === 'purple' ? '#a855f7' : '#ef4444'
}}
/>
</div>
@@ -665,7 +658,7 @@ export default function NewWorkflow() {
<AlertCircle className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<span>
<strong>Dynamic First Stage:</strong> When a Product inherits this workflow, it will automatically start in stage <strong>"{
[...formik.values.stages].sort((a,b)=>a.order - b.order)[0]?.name || "Draft"
[...formik.values.stages].sort((a, b) => a.order - b.order)[0]?.name || "Draft"
}"</strong>. This is determined dynamically from your Stage orders.
</span>
</div>
+11 -13
View File
@@ -6,7 +6,7 @@ import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { WorkflowStatsCard } from "../components/WorkflowStatsCard";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useWorkflow } from "../hook/useWorkflow";
import { StageTimeline } from "../components/StageTimeline";
@@ -188,28 +188,28 @@ export default function WorkflowList() {
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<WorkflowStatsCard
<StatsCard
title="Total Workflows"
value={stats.total.toString()}
subtitle="Dynamic templates"
color="purple"
icon={<Layers className="w-5 h-5" />}
/>
<WorkflowStatsCard
<StatsCard
title="Active Workflows"
value={stats.active.toString()}
subtitle="Currently running"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<WorkflowStatsCard
<StatsCard
title="Product Families Assigned"
value={stats.totalFamilies.toString()}
subtitle="Inheriting configs"
color="blue"
icon={<Activity className="w-5 h-5" />}
/>
<WorkflowStatsCard
<StatsCard
title="Total Products Assigned"
value={stats.totalProducts.toString()}
subtitle="In lifecycle workflow"
@@ -292,10 +292,9 @@ export default function WorkflowList() {
<div>
<span className="text-gray-400">Status</span>
<p className="mt-0.5">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
selectedWorkflow.deletedAt ? 'bg-red-50 text-red-700' :
selectedWorkflow.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-slate-50 text-slate-700'
}`}>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${selectedWorkflow.deletedAt ? 'bg-red-50 text-red-700' :
selectedWorkflow.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-slate-50 text-slate-700'
}`}>
{selectedWorkflow.deletedAt ? 'Archived' : selectedWorkflow.status}
</span>
</p>
@@ -349,9 +348,8 @@ export default function WorkflowList() {
<td className="p-3 font-mono text-[10px] text-gray-500">{selectedWorkflow.code}</td>
<td className="p-3 font-bold text-gray-800">{f.productCount}</td>
<td className="p-3">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
f.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-slate-50 text-slate-700'
}`}>
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${f.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-slate-50 text-slate-700'
}`}>
{f.status}
</span>
</td>
@@ -368,7 +366,7 @@ export default function WorkflowList() {
</div>
</div>
</div>
{/* Drawer Footer Actions */}
<div className="border-t border-gray-200 p-4 bg-gray-50 flex justify-end gap-2 shrink-0">
<Button variant="outline" size="sm" className="bg-white" onClick={() => navigate(`${selectedWorkflow.id}/edit`)}>Edit Definition</Button>
+2 -1
View File
@@ -11,8 +11,9 @@
@theme {
/* --- Brand / Primary (Royal Purple — matches DEFAULT_THEME in theme.service.ts) --- */
--color-primary: #7C3AED; /* violet-600 */
--color-primary-hover: #6D28D9; /* violet-700 */
--color-primary-hover: #C4B5FD; /* violet-700 */
--color-primary-light: #DDD6FE; /* violet-200 */
--color-primary-ghost: #EDE9FE; /* violet-100 — ultra-light, used for sidebar hover */
--color-primary-dark: #4C1D95; /* violet-900 */
/* --- Semantic --- */
+1 -1
View File
@@ -48,7 +48,7 @@ const AppRoutes = () => {
{/* Protected Routes with Layout */}
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
<Route path="/dashboard" element={<DashboardRoutes />} />
{/* Catalog */}
<Route path="/products/*" element={<ProductRoutes />} />
<Route path="/families/*" element={<FamilyRoutes />} />
+10 -7
View File
@@ -80,13 +80,16 @@ export const sidebarConfig: SidebarItem[] = [
]
},
{
label: 'Master Data',
href: '/master-data',
icon: Database,
children: [
{ label: 'Units', href: '/units', icon: Ruler, permission: 'masters.units' },
{ label: 'Brands', href: '/brands', icon: Award, permission: 'masters.brands' }
]
label: 'Units',
href: '/units',
icon: Ruler,
permission: 'masters.units'
},
{
label: 'Brands',
href: '/brands',
icon: Award,
permission: 'masters.brands'
},
{
label: 'Asset Management',
+1 -1
View File
@@ -21,7 +21,7 @@
padding: 14px 40px 14px 14px;
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
background-color: var(--color-surface, #ffffff);
background-color: var(--color-primary-ghost, #DDD6FE);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.08), 0 2px 6px -1px rgba(0, 0, 0, 0.05);
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
+2
View File
@@ -35,6 +35,8 @@ export interface ThemePalette {
primaryHover: string;
/** Light tint of primary — maps to --color-primary-light */
primaryLight: string;
/** Ultra-light ghost tint of primary — maps to --color-primary-ghost (sidebar hover, toast bg) */
primaryGhost: string;
/** Dark shade of primary — maps to --color-primary-dark */
primaryDark: string;
/** Page background — maps to --color-background */
+1
View File
@@ -28,6 +28,7 @@ const CSS_VARIABLE_MAP: Readonly<Record<keyof ThemePalette, string>> = {
primary: "--color-primary",
primaryHover: "--color-primary-hover",
primaryLight: "--color-primary-light",
primaryGhost: "--color-primary-ghost",
primaryDark: "--color-primary-dark",
background: "--color-background",
surface: "--color-surface",
+13
View File
@@ -0,0 +1,13 @@
/**
* Auto-generates a clean code string from a given name.
* Sanitizes input to lowercase, replaces spaces and hyphens with underscores,
* and strips any invalid characters to comply with /^[a-z0-9_-]+$/ validation.
*/
export const generateCodeFromName = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s_-]/g, "") // strip special characters except space, underscore, hyphen
.replace(/[\s-]+/g, "_") // replace spaces and hyphens with underscores
.replace(/_+/g, "_"); // merge consecutive underscores
};