solved merge conflicts
This commit is contained in:
Generated
+327
-40
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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";
|
||||
}
|
||||
|
||||
// Re‑use 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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",
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 --- */
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user