merge conflict
This commit is contained in:
Generated
+105
-75
@@ -8,6 +8,7 @@
|
||||
"name": "pim",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.17",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
@@ -23,6 +24,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-is": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
@@ -300,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": {
|
||||
@@ -311,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": {
|
||||
@@ -496,6 +498,18 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -626,9 +640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"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,
|
||||
@@ -1356,9 +1370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"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"
|
||||
],
|
||||
@@ -1374,9 +1388,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -1392,9 +1406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"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"
|
||||
],
|
||||
@@ -1410,9 +1424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -1428,9 +1442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"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"
|
||||
],
|
||||
@@ -1446,9 +1460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"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"
|
||||
],
|
||||
@@ -1464,9 +1478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"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"
|
||||
],
|
||||
@@ -1482,9 +1496,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"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"
|
||||
],
|
||||
@@ -1500,9 +1514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -1518,9 +1532,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"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"
|
||||
],
|
||||
@@ -1536,9 +1550,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -1554,9 +1568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"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"
|
||||
],
|
||||
@@ -1572,9 +1586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"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"
|
||||
],
|
||||
@@ -1583,8 +1597,8 @@
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@emnapi/core": "1.11.2",
|
||||
"@emnapi/runtime": "1.11.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1592,9 +1606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"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"
|
||||
],
|
||||
@@ -1610,9 +1624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"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"
|
||||
],
|
||||
@@ -4316,6 +4330,22 @@
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.81.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
|
||||
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
@@ -4533,14 +4563,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"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.138.0",
|
||||
"@oxc-project/types": "=0.140.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4550,21 +4580,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
"@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": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.17",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
@@ -27,6 +28,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-is": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
// import { type ButtonHTMLAttributes } from 'react';
|
||||
// import { usePermissions } from '../../hooks/usePermissions';
|
||||
import React, { type ButtonHTMLAttributes } from 'react';
|
||||
import { usePermissions } from '../../hooks/usePermission';
|
||||
|
||||
// interface PermissionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// module: string;
|
||||
// action: string;
|
||||
// children: React.ReactNode;
|
||||
// }
|
||||
interface PermissionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
module: string;
|
||||
action: 'view' | 'create' | 'edit' | 'delete' | 'alter' | 'import' | 'export';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// // Button that only renders if user has permission
|
||||
// export const PermissionButton = ({
|
||||
// module,
|
||||
// action,
|
||||
// children,
|
||||
// className = '',
|
||||
// ...props
|
||||
// }: PermissionButtonProps) => {
|
||||
// const { hasPermission } = usePermissions();
|
||||
// Button that only renders if user has permission
|
||||
export const PermissionButton: React.FC<PermissionButtonProps> = ({
|
||||
module,
|
||||
action,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const perms = usePermissions(module);
|
||||
|
||||
// if (!hasPermission(module, action)) {
|
||||
// return null;
|
||||
// }
|
||||
let allowed = false;
|
||||
if (action === 'view') allowed = perms.canView;
|
||||
else if (action === 'create') allowed = perms.canCreate;
|
||||
else if (action === 'edit') allowed = perms.canEdit;
|
||||
else if (action === 'delete') allowed = perms.canDelete;
|
||||
else if (action === 'alter') allowed = perms.canAlter;
|
||||
else if (action === 'import') allowed = perms.canImport;
|
||||
else if (action === 'export') allowed = perms.canExport;
|
||||
|
||||
// return (
|
||||
// <button
|
||||
// className={`px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 ${className}`}
|
||||
// {...props}
|
||||
// >
|
||||
// {children}
|
||||
// </button>
|
||||
// );
|
||||
// };
|
||||
if (!allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionButton;
|
||||
@@ -1,25 +1,36 @@
|
||||
// import { type ReactNode } from 'react';
|
||||
// import { usePermissions } from '../../hooks/usePermissions';
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { usePermissions } from '../../hooks/usePermission';
|
||||
|
||||
// interface PermissionWrapperProps {
|
||||
// children: ReactNode;
|
||||
// module: string;
|
||||
// action?: string;
|
||||
// fallback?: ReactNode;
|
||||
// }
|
||||
interface PermissionWrapperProps {
|
||||
children: ReactNode;
|
||||
module: string;
|
||||
action?: 'view' | 'create' | 'edit' | 'delete' | 'alter' | 'import' | 'export';
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
// // Wrapper component for conditional rendering based on permissions
|
||||
// export const PermissionWrapper = ({
|
||||
// children,
|
||||
// module,
|
||||
// action = 'view',
|
||||
// fallback = null
|
||||
// }: PermissionWrapperProps) => {
|
||||
// const { hasPermission } = usePermissions();
|
||||
// Wrapper component for conditional rendering based on permissions
|
||||
export const PermissionWrapper: React.FC<PermissionWrapperProps> = ({
|
||||
children,
|
||||
module,
|
||||
action = 'view',
|
||||
fallback = null
|
||||
}) => {
|
||||
const perms = usePermissions(module);
|
||||
|
||||
// if (!hasPermission(module, action)) {
|
||||
// return <>{fallback}</>;
|
||||
// }
|
||||
let allowed = false;
|
||||
if (action === 'view') allowed = perms.canView;
|
||||
else if (action === 'create') allowed = perms.canCreate;
|
||||
else if (action === 'edit') allowed = perms.canEdit;
|
||||
else if (action === 'delete') allowed = perms.canDelete;
|
||||
else if (action === 'alter') allowed = perms.canAlter;
|
||||
else if (action === 'import') allowed = perms.canImport;
|
||||
else if (action === 'export') allowed = perms.canExport;
|
||||
|
||||
// return <>{children}</>;
|
||||
// };
|
||||
if (!allowed) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PermissionWrapper;
|
||||
@@ -2,6 +2,8 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../store';
|
||||
import { usePermissions } from '../../hooks/usePermission';
|
||||
import { AccessDenied } from '../../components/customs/AccessDenied';
|
||||
|
||||
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated } = useAppSelector((state) => state.auth);
|
||||
@@ -14,13 +16,18 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// ProtectedRoute for permissions (still useful)
|
||||
// ProtectedRoute for permissions
|
||||
export const ProtectedRoute: React.FC<{
|
||||
node?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
// For now, allow everything in development
|
||||
// You can later connect real permission logic
|
||||
fallback?: React.ReactNode;
|
||||
}> = ({ node, children, fallback = <AccessDenied /> }) => {
|
||||
const { canView } = usePermissions(node || '');
|
||||
|
||||
if (node && !canView) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,13 +8,21 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./Dropdown";
|
||||
|
||||
export interface CustomAction {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ActionMenuProps {
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
customActions?: CustomAction[];
|
||||
}
|
||||
|
||||
export function ActionMenu({ onView, onEdit, onDelete }: ActionMenuProps) {
|
||||
export function ActionMenu({ onView, onEdit, onDelete, customActions }: ActionMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -59,6 +68,19 @@ export function ActionMenu({ onView, onEdit, onDelete }: ActionMenuProps) {
|
||||
<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>
|
||||
))}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Search, X, ChevronsUpDown, ChevronUp, ChevronDown,
|
||||
ChevronLeft, ChevronRight, Settings2, Check, Filter
|
||||
} from "lucide-react";
|
||||
import { ActionMenu } from "./ActionMenu";
|
||||
import { ActionMenu, type CustomAction } 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;
|
||||
@@ -36,6 +37,7 @@ export interface DataTableProps<T = any> {
|
||||
onView?: (row: T) => void;
|
||||
onEdit?: (row: T) => void;
|
||||
onDelete?: (row: T) => void;
|
||||
customActions?: (row: T) => CustomAction[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ export function DataTable<T extends Record<string, any> = any>({
|
||||
searchPlaceholder = "Search...",
|
||||
statusKey = "status",
|
||||
actionConfig,
|
||||
loading = false,
|
||||
}: DataTableProps<T>) {
|
||||
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -327,7 +330,16 @@ export function DataTable<T extends Record<string, any> = any>({
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-border">
|
||||
{paged.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-6 py-14 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-6 py-14 text-center text-muted-foreground">
|
||||
No records found
|
||||
@@ -352,7 +364,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>
|
||||
@@ -374,6 +386,7 @@ export function DataTable<T extends Record<string, any> = any>({
|
||||
onView={actionConfig.onView ? () => actionConfig.onView!(row) : undefined}
|
||||
onEdit={actionConfig.onEdit ? () => actionConfig.onEdit!(row) : undefined}
|
||||
onDelete={actionConfig.onDelete ? () => actionConfig.onDelete!(row) : undefined}
|
||||
customActions={actionConfig.customActions ? actionConfig.customActions(row) : undefined}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Option {
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
onChange?: (e: any) => void;
|
||||
@@ -20,6 +21,7 @@ interface SelectProps {
|
||||
}
|
||||
|
||||
export function Select({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
@@ -97,6 +99,7 @@ export function Select({
|
||||
<div className="relative w-full">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
export type BadgeVariant =
|
||||
| "active"
|
||||
| "draft"
|
||||
| "inactive"
|
||||
| "disabled"
|
||||
| "published"
|
||||
| "pending"
|
||||
@@ -21,6 +22,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",
|
||||
published: "bg-primary-light text-primary-dark border border-primary/20",
|
||||
pending: "bg-amber-100 text-amber-700 border border-amber-200",
|
||||
@@ -38,6 +40,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",
|
||||
published: "bg-primary",
|
||||
pending: "bg-amber-500",
|
||||
|
||||
@@ -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) {
|
||||
notify.error(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) {
|
||||
notify.error(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) {
|
||||
notify.error(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) {
|
||||
notify.error(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,345 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Tag, Image as ImageIcon, FileText, Video, Edit2, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Image as ImageIcon, Edit2, Search, RefreshCw, Layers } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
|
||||
const MOCK_FAMILIES = [
|
||||
{
|
||||
id: "f1",
|
||||
name: "Product Photos",
|
||||
description: "Main product imagery for ecommerce and marketing",
|
||||
code: "product_photos",
|
||||
type: "IMAGE",
|
||||
assets: "8,942",
|
||||
icon: ImageIcon,
|
||||
iconBg: "bg-blue-50",
|
||||
iconColor: "text-blue-600",
|
||||
status: "active",
|
||||
attributes: [
|
||||
{ code: "alt_text", label: "Alt Text", type: "text", required: true, options: null },
|
||||
{ code: "copyright", label: "Copyright", type: "text", required: true, options: null },
|
||||
{ code: "photographer", label: "Photographer", type: "text", required: false, options: null },
|
||||
{ code: "shoot_date", label: "Shoot Date", type: "date", required: false, options: null },
|
||||
{ code: "usage_rights", label: "Usage Rights", type: "select", required: true, options: "3 options" },
|
||||
{ code: "locale", label: "Locale", type: "select", required: false, options: "45 options" },
|
||||
{ code: "background_color", label: "Background Color", type: "text", required: false, options: null },
|
||||
{ code: "is_main", label: "Is Main Image", type: "boolean", required: false, options: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
name: "Marketing Images",
|
||||
description: "Banners, campaigns, and promotional imagery",
|
||||
code: "marketing_images",
|
||||
type: "IMAGE",
|
||||
assets: "1,247",
|
||||
icon: ImageIcon,
|
||||
iconBg: "bg-primary/5",
|
||||
iconColor: "text-primary",
|
||||
status: "active",
|
||||
attributes: [
|
||||
{ code: "campaign_name", label: "Campaign Name", type: "text", required: true, options: null },
|
||||
{ code: "channel", label: "Channel", type: "select", required: true, options: "12 options" },
|
||||
{ code: "valid_from", label: "Valid From", type: "date", required: false, options: null },
|
||||
{ code: "valid_to", label: "Valid To", type: "date", required: false, options: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f3",
|
||||
name: "Technical Documentation",
|
||||
description: "User manuals, spec sheets, and technical diagrams",
|
||||
code: "technical_docs",
|
||||
type: "DOCUMENT",
|
||||
assets: "543",
|
||||
icon: FileText,
|
||||
iconBg: "bg-amber-50",
|
||||
iconColor: "text-amber-600",
|
||||
status: "active",
|
||||
attributes: [
|
||||
{ code: "doc_version", label: "Document Version", type: "text", required: true, options: null },
|
||||
{ code: "language", label: "Language", type: "select", required: true, options: "30 options" },
|
||||
{ code: "revision_date", label: "Revision Date", type: "date", required: false, options: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "f4",
|
||||
name: "Product Videos",
|
||||
description: "Product demonstrations and promotional videos",
|
||||
code: "product_videos",
|
||||
type: "VIDEO",
|
||||
assets: "287",
|
||||
icon: Video,
|
||||
iconBg: "bg-red-50",
|
||||
iconColor: "text-red-600",
|
||||
status: "inactive",
|
||||
attributes: [
|
||||
{ code: "duration", label: "Duration (secs)", type: "text", required: true, options: null },
|
||||
{ code: "resolution", label: "Resolution", type: "select", required: true, options: "5 options" },
|
||||
{ code: "captions", label: "Has Captions", type: "boolean", required: false, options: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
IMAGE: "text-blue-600 bg-blue-50 border-blue-200",
|
||||
DOCUMENT: "text-amber-600 bg-amber-50 border-amber-200",
|
||||
VIDEO: "text-red-600 bg-red-50 border-red-200",
|
||||
};
|
||||
|
||||
type Family = typeof MOCK_FAMILIES[number];
|
||||
import { useAssetFamily } from "../hook/useAssetFamily";
|
||||
|
||||
export default function AssetFamilyList() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Family>(MOCK_FAMILIES[0]);
|
||||
const { items, loading, fetchItems, deleteItem } = useAssetFamily();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedFamily, setSelectedFamily] = useState<Family | null>(null);
|
||||
const [selectedFamilyId, setSelectedFamilyId] = useState<string | null>(null);
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
|
||||
const filteredTabs = MOCK_FAMILIES.filter((f) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase())
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const filteredItems = items.filter((f) =>
|
||||
(f.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||
(f.code || "").toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "FAMILY NAME",
|
||||
sortable: true,
|
||||
render: (_: any, row: Family) => {
|
||||
const Icon = row.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${row.iconBg}`}>
|
||||
<Icon className={`w-4 h-4 ${row.iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{row.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate max-w-xs">{row.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "CODE",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">
|
||||
{val}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: "TYPE",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<span className={`px-2.5 py-1 rounded text-xs font-bold border ${TYPE_COLORS[val]}`}>
|
||||
{val}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "ASSETS",
|
||||
sortable: true,
|
||||
render: (val: string) => <span className="font-medium text-gray-900">{val}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${val === "active" ? "bg-emerald-500" : "bg-gray-400"}`} />
|
||||
<span className={`text-xs font-medium capitalize ${val === "active" ? "text-emerald-700" : "text-gray-500"}`}>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "attributes",
|
||||
label: "ATTRIBUTES",
|
||||
render: (_: any, row: Family) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-primary">
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{row.attributes.length} fields
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
const activeFamily = items.find(f => f.id === selectedFamilyId) || items[0] || null;
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
try {
|
||||
await deleteItem(deleteModal.id);
|
||||
setDeleteModal({ isOpen: false, id: "", name: "" });
|
||||
} catch {
|
||||
// Handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Asset Families" }]}
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Asset Family
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" className="text-gray-700 bg-white" onClick={() => fetchItems()}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Asset Family
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-0 bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Vertical Tabs — Left */}
|
||||
<div className="w-64 shrink-0 border-r border-gray-200 flex flex-col">
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search families..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{filteredTabs.map((family) => {
|
||||
const Icon = family.icon;
|
||||
const isActive = activeTab.id === family.id;
|
||||
return (
|
||||
<button
|
||||
key={family.id}
|
||||
onClick={() => { setActiveTab(family); setSelectedFamily(null); }}
|
||||
className={`w-full text-left flex items-start gap-3 px-3 py-3 rounded-lg mb-1 transition-all ${
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/20 ring-1 ring-primary/10"
|
||||
: "border border-transparent hover:bg-gray-50 hover:border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${family.iconBg}`}>
|
||||
<Icon className={`w-4 h-4 ${family.iconColor}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm font-semibold truncate ${isActive ? "text-primary-dark" : "text-gray-900"}`}>
|
||||
{family.name}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate mt-0.5">{family.description}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{family.code}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">{family.assets} assets</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center flex flex-col items-center">
|
||||
<ImageIcon className="w-12 h-12 text-gray-300 mb-3" />
|
||||
<h3 className="font-bold text-gray-700 text-base">No Asset Families Found</h3>
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-sm">Create asset families to group media asset requirements across products.</p>
|
||||
<Button variant="primary" className="mt-4" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Asset Family
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
{/* Active Tab Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-primary/5 to-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-1 h-5 bg-primary-light rounded-full shrink-0`} />
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${activeTab.iconBg}`}>
|
||||
<activeTab.icon className={`w-5 h-5 ${activeTab.iconColor}`} />
|
||||
) : (
|
||||
<div className="flex gap-0 bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden min-h-[500px]">
|
||||
{/* Vertical Sidebar — Left */}
|
||||
<div className="w-72 shrink-0 border-r border-gray-200 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search asset families..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-primary-dark text-sm">{activeTab.name}</h3>
|
||||
<p className="text-xs text-gray-500">{activeTab.description}</p>
|
||||
</div>
|
||||
<span className={`ml-2 px-2.5 py-0.5 rounded text-xs font-bold border ${TYPE_COLORS[activeTab.type]}`}>
|
||||
{activeTab.type}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-3.5 h-3.5" />}
|
||||
onClick={() => navigate(`${activeTab.id}/edit`)}
|
||||
>
|
||||
Edit Family
|
||||
</Button>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{filteredItems.map((family) => {
|
||||
const isActive = activeFamily?.id === family.id;
|
||||
const types = family.assetTypes || [];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={family.id}
|
||||
onClick={() => setSelectedFamilyId(family.id)}
|
||||
className={`w-full text-left flex items-start gap-3 px-3 py-3 rounded-lg mb-1 transition-all ${
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/20 ring-1 ring-primary/10"
|
||||
: "border border-transparent hover:bg-gray-50 hover:border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 mt-0.5 bg-blue-50">
|
||||
<ImageIcon className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-sm font-semibold truncate ${isActive ? "text-primary-dark" : "text-gray-900"}`}>
|
||||
{family.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{family.code || "family"}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 font-medium">
|
||||
{types.length} Types
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DataTable for active tab */}
|
||||
<div className="p-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={[activeTab]}
|
||||
searchPlaceholder="Search..."
|
||||
resultLabel="families"
|
||||
onRowClick={(row) => setSelectedFamily(row === selectedFamily ? null : row)}
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attributes Detail Panel */}
|
||||
{selectedFamily && selectedFamily.id === activeTab.id && (
|
||||
<div className="mx-4 mb-4 bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="px-6 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-gray-900 text-sm">Asset Attributes</span>
|
||||
<span className="text-xs text-gray-500">— {selectedFamily.attributes.length} metadata fields</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedFamily(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-4 px-6 py-3 border-b border-gray-100 bg-gray-50/50">
|
||||
{["Attribute Code", "Label", "Type", "Required", "Options"].map((h) => (
|
||||
<div key={h} className="text-xs font-bold text-gray-500 tracking-wider uppercase">{h}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{selectedFamily.attributes.map((attr, i) => (
|
||||
<div key={i} className="grid grid-cols-5 gap-4 px-6 py-3 hover:bg-gray-50/70 transition-colors">
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200 self-start">
|
||||
{attr.code}
|
||||
</code>
|
||||
<div className="text-sm text-gray-900 font-medium">{attr.label}</div>
|
||||
<div className="text-sm text-gray-600">{attr.type}</div>
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-gray-50/20">
|
||||
{activeFamily ? (
|
||||
<>
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-primary rounded-full shrink-0" />
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-blue-50">
|
||||
<ImageIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
{attr.required ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-red-50 text-red-600 border border-red-200">Required</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Optional</span>
|
||||
<h3 className="font-semibold text-gray-900 text-base">{activeFamily.name}</h3>
|
||||
<p className="text-xs text-gray-500">{activeFamily.description || "No description"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-3.5 h-3.5" />}
|
||||
onClick={() => navigate(`${activeFamily.id}/edit`)}
|
||||
>
|
||||
Edit Asset Family
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
{/* Asset Types Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-2xs overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-gray-800 uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
Asset Types in this Asset Family
|
||||
</h4>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{(activeFamily.assetTypes || []).length} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100">
|
||||
{(activeFamily.assetTypes || []).map((at: any, idx: number) => (
|
||||
<div key={at.id || idx} className="p-4 flex items-center justify-between hover:bg-gray-50/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary font-bold text-xs flex items-center justify-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-900">{at.name}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5 font-mono">{at.code}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-lg text-xs font-medium capitalize bg-gray-100 text-gray-700">
|
||||
{at.category || 'image'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!activeFamily.assetTypes || activeFamily.assetTypes.length === 0) && (
|
||||
<div className="p-8 text-center text-xs text-gray-400">
|
||||
No asset types mapped to this asset family yet. Click <span className="font-semibold text-primary">Edit Asset Family</span> to select.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{attr.options || "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs">
|
||||
Select an asset family from the left list.
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t border-gray-100 bg-gray-50/30">
|
||||
<button className="flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-dark transition-colors">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Attribute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Asset Family"
|
||||
description="Are you sure you want to delete this asset family? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { Save, Library } from "lucide-react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Save, Library, Search, CheckCircle2, Sliders, X, ChevronDown } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Input } from "../../../components/customs/Input";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { useAssetFamily } from "../hook/useAssetFamily";
|
||||
import { assetFamilySchema } from "../validation/asset-families.schema";
|
||||
import { useAssetType } from "../../asset-types/hook/useAssetType";
|
||||
import { assetFamilySchema, type AssetFamilyFormData } from "../validation/asset-families.schema";
|
||||
import type { AssetFamilyCreateRequest } from "../types/asset-families.types";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active', desc: 'Family will be available for new assets' },
|
||||
{ value: 'inactive', label: 'Inactive', desc: 'Family will be hidden from new assets' },
|
||||
{ value: 'active', label: 'Active', desc: 'Asset Family is active and available' },
|
||||
{ value: 'inactive', label: 'Inactive', desc: 'Asset Family is hidden from selection' },
|
||||
];
|
||||
|
||||
const errorClass = 'text-xs text-red-500 mt-1.5 flex items-center gap-1';
|
||||
const errorClass = 'text-xs text-red-500 mt-1.5 flex items-center gap-1 font-medium';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-2';
|
||||
|
||||
export default function NewAssetFamily() {
|
||||
@@ -25,127 +27,396 @@ export default function NewAssetFamily() {
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { createItem, updateItem, items, fetchItems } = useAssetFamily();
|
||||
const { items: availableAssetTypes, fetchItems: fetchAssetTypes } = useAssetType();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors, isSubmitting }
|
||||
} = useForm<AssetFamilyFormData>({
|
||||
resolver: yupResolver(assetFamilySchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
assetTypeIds: []
|
||||
}
|
||||
});
|
||||
|
||||
const selectedAssetTypeIds = watch("assetTypeIds") || [];
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
fetchAssetTypes();
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
},
|
||||
validationSchema: assetFamilySchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, values as any);
|
||||
} else {
|
||||
await createItem(values as AssetFamilyCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// Handled by service/hook
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
// Click outside listener for dropdown
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id && items.length > 0) {
|
||||
const match = items.find((item) => item.id === id);
|
||||
if (match) {
|
||||
formik.setValues({
|
||||
name: match.name,
|
||||
status: match.status as "active" | "inactive",
|
||||
const typeIds = match.assetTypeIds || (match.assetTypes || []).map(at => at.id);
|
||||
reset({
|
||||
name: match.name || "",
|
||||
code: match.code || "",
|
||||
description: match.description || "",
|
||||
status: (match.status as "active" | "inactive") || "active",
|
||||
assetTypeIds: typeIds
|
||||
});
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, id, items]);
|
||||
}, [isEdit, id, items, reset]);
|
||||
|
||||
const onSubmit = async (values: AssetFamilyFormData) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, values as any);
|
||||
} else {
|
||||
await createItem(values as AssetFamilyCreateRequest);
|
||||
}
|
||||
navigate("/asset-families");
|
||||
} catch {
|
||||
// Handled in toast
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (!searchQuery.trim()) return availableAssetTypes;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return availableAssetTypes.filter(
|
||||
at => at.name.toLowerCase().includes(q) || at.code.toLowerCase().includes(q) || (at.category && at.category.toLowerCase().includes(q))
|
||||
);
|
||||
}, [availableAssetTypes, searchQuery]);
|
||||
|
||||
// First 6 Asset Types for Cards view
|
||||
const cardAssetTypes = useMemo(() => {
|
||||
return filteredAssetTypes.slice(0, 6);
|
||||
}, [filteredAssetTypes]);
|
||||
|
||||
// Remaining Asset Types beyond the first 6 for Dropdown view
|
||||
const dropdownAssetTypes = useMemo(() => {
|
||||
const cardIds = new Set(cardAssetTypes.map(at => at.id));
|
||||
return filteredAssetTypes.filter(at => !cardIds.has(at.id));
|
||||
}, [filteredAssetTypes, cardAssetTypes]);
|
||||
|
||||
const selectedTypeIdsSet = useMemo(() => {
|
||||
return new Set(selectedAssetTypeIds);
|
||||
}, [selectedAssetTypeIds]);
|
||||
|
||||
const toggleAssetTypeSelection = (assetTypeId: string) => {
|
||||
const current = [...selectedAssetTypeIds];
|
||||
const index = current.indexOf(assetTypeId);
|
||||
if (index > -1) {
|
||||
current.splice(index, 1);
|
||||
} else {
|
||||
current.push(assetTypeId);
|
||||
}
|
||||
setValue("assetTypeIds", current, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const selectedTypesList = useMemo(() => {
|
||||
return availableAssetTypes.filter(at => selectedTypeIdsSet.has(at.id));
|
||||
}, [availableAssetTypes, selectedTypeIdsSet]);
|
||||
|
||||
// Selected asset types that aren't in the top 6 card view
|
||||
const additionalSelectedTypes = useMemo(() => {
|
||||
const cardIds = new Set(cardAssetTypes.map(at => at.id));
|
||||
return selectedTypesList.filter(at => !cardIds.has(at.id));
|
||||
}, [selectedTypesList, cardAssetTypes]);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Asset Families', href: '/asset-families' }, { label: isEdit ? 'Edit Asset Family' : 'Create Asset Family' }]}
|
||||
items={[
|
||||
{ label: 'Home' },
|
||||
{ label: 'Asset Families', href: '/asset-families' },
|
||||
{ label: isEdit ? 'Edit Asset Family' : 'Create Asset Family' }
|
||||
]}
|
||||
backTo="/asset-families"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="asset-family-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Asset Family' : 'Create Asset Family'}</Button>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/asset-families')} disabled={isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="asset-family-form" icon={<Save className="w-4 h-4" />} loading={isSubmitting}>
|
||||
{isEdit ? 'Update Asset Family' : 'Create Asset Family'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<form id="asset-family-form" onSubmit={formik.handleSubmit} className="flex-1 max-w-4xl space-y-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Information</h2>
|
||||
<form id="asset-family-form" onSubmit={handleSubmit(onSubmit)} className="flex-1 max-w-4xl space-y-8">
|
||||
|
||||
{/* Basic Information Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-xs space-y-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 flex items-center gap-2 border-b border-gray-100 pb-3">
|
||||
<Library className="w-4 h-4 text-primary" />
|
||||
Basic Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<div>
|
||||
<label className={labelClass}>Name *</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Media Assets"
|
||||
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
|
||||
{...register("name")}
|
||||
placeholder="e.g. Marketing Assets"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
{errors.name && <p className={errorClass}>{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Code</label>
|
||||
<Input
|
||||
{...register("code")}
|
||||
placeholder="e.g. marketing_assets (Auto-generated if empty)"
|
||||
/>
|
||||
{errors.code && <p className={errorClass}>{errors.code.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
<label className={labelClass}>Description</label>
|
||||
<textarea
|
||||
{...register("description")}
|
||||
rows={2}
|
||||
placeholder="Describe the asset requirements or purpose for this asset family..."
|
||||
className="w-full px-3.5 py-2.5 text-xs bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
{errors.description && <p className={errorClass}>{errors.description.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Status *</label>
|
||||
<Controller
|
||||
name="status"
|
||||
value={formik.values.status}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select {...field}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.status && <p className={errorClass}>{errors.status.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Types Selection Card */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-xs space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-gray-100 pb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4 text-primary" />
|
||||
Select Asset Types
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Top 6 asset types are shown as cards. Select additional asset types using the checkbox dropdown below.</p>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search asset types..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-xs border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Asset Types Cards (First 6) with Left Checkboxes */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-3">
|
||||
Featured Asset Types ({cardAssetTypes.length})
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{cardAssetTypes.map((at) => {
|
||||
const isSelected = selectedTypeIdsSet.has(at.id);
|
||||
return (
|
||||
<div
|
||||
key={at.id}
|
||||
onClick={() => toggleAssetTypeSelection(at.id)}
|
||||
className={`cursor-pointer p-3.5 rounded-xl border transition-all flex items-center justify-between ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-xs'
|
||||
: 'border-gray-200 hover:border-primary/30 hover:bg-gray-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // toggled by parent div
|
||||
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary/20 cursor-pointer shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-xs text-gray-900">{at.name}</div>
|
||||
<div className="text-[10px] text-gray-400 capitalize">{at.category || 'Asset'} • {at.code}</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2 className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{cardAssetTypes.length === 0 && (
|
||||
<div className="col-span-3 text-center py-4 text-xs text-gray-400">No asset types available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Asset Types Dropdown with Checkbox on Left Side */}
|
||||
{dropdownAssetTypes.length > 0 && (
|
||||
<div className="pt-4 border-t border-gray-100 space-y-3">
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Additional Asset Types ({dropdownAssetTypes.length})
|
||||
</label>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-xs text-gray-700 hover:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all shadow-2xs"
|
||||
>
|
||||
<span className="font-medium truncate">
|
||||
{additionalSelectedTypes.length > 0
|
||||
? `${additionalSelectedTypes.length} additional asset type(s) selected`
|
||||
: "Select additional asset types from dropdown..."}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isDropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute z-30 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-xl p-2 max-h-64 overflow-y-auto space-y-1">
|
||||
{dropdownAssetTypes.map((at) => {
|
||||
const isSelected = selectedTypeIdsSet.has(at.id);
|
||||
return (
|
||||
<label
|
||||
key={at.id}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer text-xs transition-colors ${
|
||||
isSelected ? 'bg-primary/5 text-primary-dark font-medium' : 'hover:bg-gray-50 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAssetTypeSelection(at.id)}
|
||||
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary/20 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate">{at.name}</div>
|
||||
<div className="text-[10px] text-gray-400 capitalize truncate">{at.category || 'Asset'} • {at.code}</div>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Additional Asset Types Badges */}
|
||||
{additionalSelectedTypes.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-2">
|
||||
Selected via Dropdown ({additionalSelectedTypes.length}):
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{additionalSelectedTypes.map((at) => (
|
||||
<span
|
||||
key={at.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 border border-primary/20 text-xs font-medium text-primary-dark"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
onChange={() => toggleAssetTypeSelection(at.id)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-gray-300 shrink-0 cursor-pointer"
|
||||
/>
|
||||
<span>{at.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleAssetTypeSelection(at.id);
|
||||
}}
|
||||
className="hover:bg-primary/20 p-0.5 rounded-full text-primary hover:text-red-600 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.assetTypeIds && (
|
||||
<p className={errorClass}>{errors.assetTypeIds.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting} className="rounded-xl px-6">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("/asset-families")} disabled={isSubmitting} className="rounded-xl px-6">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting} className="bg-primary hover:bg-primary-hover text-white rounded-xl px-6 shadow-sm shadow-purple-200">
|
||||
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Family' : 'Create Asset Family'}
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={isSubmitting} className="bg-primary hover:bg-primary-hover text-white rounded-xl px-6 shadow-sm">
|
||||
{isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Family' : 'Create Asset Family'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="w-full lg:w-80 shrink-0">
|
||||
<div className="bg-gradient-to-br from-teal-50 via-emerald-50 to-green-50 rounded-2xl p-6 border border-emerald-100 shadow-sm">
|
||||
<div className="w-10 h-10 rounded-xl bg-white shadow-sm flex items-center justify-center mb-4">
|
||||
<Library className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-emerald-900 mb-2">About Asset Families</h3>
|
||||
<p className="text-sm text-emerald-800/80 leading-relaxed mb-4">
|
||||
Asset families let you define a hierarchy and link multiple asset types into a single group.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
|
||||
<p className="text-xs text-emerald-800/70 leading-relaxed">Ensure a product has all required assets for a family</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
|
||||
<p className="text-xs text-emerald-800/70 leading-relaxed">Configure unified validation rules</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
|
||||
<p className="text-xs text-emerald-800/70 leading-relaxed">Export bundled assets efficiently</p>
|
||||
</div>
|
||||
{/* Selected Asset Types Sidebar Summary */}
|
||||
<div className="w-full lg:w-80 shrink-0 space-y-6">
|
||||
<div className="bg-white rounded-2xl p-6 border border-gray-200 shadow-xs space-y-4">
|
||||
<h3 className="font-semibold text-gray-900 text-sm flex items-center gap-2 border-b border-gray-100 pb-3">
|
||||
<Library className="w-4 h-4 text-primary" />
|
||||
Selected Asset Types ({selectedTypesList.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto pr-1 text-xs">
|
||||
{selectedTypesList.map(at => (
|
||||
<div key={at.id} className="flex items-center justify-between p-2.5 rounded-lg bg-gray-50 border border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
onChange={() => toggleAssetTypeSelection(at.id)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-gray-300 cursor-pointer shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-gray-800">{at.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAssetTypeSelection(at.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors ml-2"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedTypesList.length === 0 && (
|
||||
<div className="text-center py-4 text-xs text-gray-400 italic">No asset types selected yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,28 +10,27 @@ interface ApiResponse<T> {
|
||||
|
||||
export const assetFamiliesService = {
|
||||
getAll: async (): Promise<AssetFamily[]> => {
|
||||
// Assuming backend maps this to /api/v1/media/asset-families
|
||||
const res = await apiClient.get<ApiResponse<AssetFamily[]>>('/api/v1/media/asset-families');
|
||||
const res = await apiClient.get<ApiResponse<AssetFamily[]>>('/api/v1/asset-families');
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<AssetFamily | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetFamily>>(`/api/v1/media/asset-families/${id}`);
|
||||
const res = await apiClient.get<ApiResponse<AssetFamily>>(`/api/v1/asset-families/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
create: async (req: AssetFamilyCreateRequest): Promise<AssetFamily> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetFamily>>('/api/v1/media/asset-families', req);
|
||||
const res = await apiClient.post<ApiResponse<AssetFamily>>('/api/v1/asset-families', req);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: AssetFamilyUpdateRequest): Promise<AssetFamily> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetFamily>>(`/api/v1/media/asset-families/${id}`, req);
|
||||
const res = await apiClient.put<ApiResponse<AssetFamily>>(`/api/v1/asset-families/${id}`, req);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/media/asset-families/${id}`);
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/asset-families/${id}`);
|
||||
return res.success;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { AssetType } from '../../asset-types/types/asset-types.types';
|
||||
|
||||
export interface AssetFamily {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
assetTypes?: AssetType[];
|
||||
assetTypeIds?: string[];
|
||||
}
|
||||
export type AssetFamilyCreateRequest = Omit<AssetFamily, 'id' | 'createdAt'>;
|
||||
export type AssetFamilyUpdateRequest = Partial<AssetFamilyCreateRequest>;
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const assetFamilySchema = Yup.object().shape({
|
||||
name: Yup.string().required('Asset Family name is required'),
|
||||
status: Yup.string().oneOf(['active', 'inactive']),
|
||||
name: Yup.string().trim().required('Asset Family name is required'),
|
||||
code: Yup.string().optional(),
|
||||
description: Yup.string().optional(),
|
||||
status: Yup.string().oneOf(['active', 'inactive']).default('active'),
|
||||
assetTypeIds: Yup.array().of(Yup.string().required()).default([]),
|
||||
});
|
||||
|
||||
export type AssetFamilyFormData = Yup.InferType<typeof assetFamilySchema>;
|
||||
|
||||
@@ -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) {
|
||||
notify.error(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) {
|
||||
notify.error(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) {
|
||||
notify.error(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) {
|
||||
notify.error(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,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Image as ImageIcon, Video, FileText, CheckCircle2, Layers, RefreshCw, LayoutGrid, BarChart2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Image as ImageIcon, Video, FileText, CheckCircle2, Layers, RefreshCw, LayoutGrid, BarChart2, Award, Megaphone, HelpCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
@@ -7,119 +7,48 @@ import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
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";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_ASSET_TYPES = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Primary Image",
|
||||
description: "Main hero image displayed as the primary product photo on all...",
|
||||
required: true,
|
||||
code: "primary_image",
|
||||
category: "Image",
|
||||
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
|
||||
icon: ImageIcon,
|
||||
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
|
||||
status: "Active",
|
||||
families: 48,
|
||||
products: "12,840",
|
||||
updatedDate: "2025-03-15",
|
||||
updatedBy: "Admin User"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Gallery Image",
|
||||
description: "Additional product images for gallery carousel and zoom...",
|
||||
required: false,
|
||||
code: "gallery_image",
|
||||
category: "Image",
|
||||
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
|
||||
icon: ImageIcon,
|
||||
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
|
||||
status: "Active",
|
||||
families: 42,
|
||||
products: "10,240",
|
||||
updatedDate: "2025-03-12",
|
||||
updatedBy: "Sarah Chen"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Thumbnail",
|
||||
description: "Small optimized image used in product listings, search results and...",
|
||||
required: false,
|
||||
code: "thumbnail",
|
||||
category: "Image",
|
||||
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
|
||||
icon: ImageIcon,
|
||||
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
|
||||
status: "Active",
|
||||
families: 38,
|
||||
products: "9,820",
|
||||
updatedDate: "2025-02-28",
|
||||
updatedBy: "Michael Torres"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "360° Image",
|
||||
description: "Sequence of images enabling interactive 360-degree product...",
|
||||
required: false,
|
||||
code: "image_360",
|
||||
category: "Image",
|
||||
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
|
||||
icon: ImageIcon,
|
||||
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
|
||||
status: "Active",
|
||||
families: 14,
|
||||
products: "2,430",
|
||||
updatedDate: "2025-03-08",
|
||||
updatedBy: "Emma Wilson"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Product Video",
|
||||
description: "Demonstration or promotional video showcasing product features...",
|
||||
required: false,
|
||||
code: "product_video",
|
||||
category: "Video",
|
||||
categoryColor: "text-red-600 bg-red-50 border-red-200",
|
||||
icon: Video,
|
||||
fileTypes: ["MP4", "MOV", "WEBM"],
|
||||
status: "Active",
|
||||
families: 22,
|
||||
products: "4,310",
|
||||
updatedDate: "2025-03-10",
|
||||
updatedBy: "James Park"
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Datasheet PDF",
|
||||
description: "Detailed technical specifications, safety information, and...",
|
||||
required: false,
|
||||
code: "datasheet_pdf",
|
||||
category: "Document",
|
||||
categoryColor: "text-amber-600 bg-amber-50 border-amber-200",
|
||||
icon: FileText,
|
||||
fileTypes: ["PDF"],
|
||||
status: "Active",
|
||||
families: 30,
|
||||
products: "7,840",
|
||||
updatedDate: "2025-03-05",
|
||||
updatedBy: "Admin User"
|
||||
}
|
||||
];
|
||||
const CATEGORY_MAP: Record<string, { icon: any; color: string }> = {
|
||||
image: { icon: ImageIcon, color: "text-blue-600 bg-blue-50 border-blue-200" },
|
||||
video: { icon: Video, color: "text-orange-600 bg-orange-50 border-orange-200" },
|
||||
document: { icon: FileText, color: "text-red-600 bg-red-50 border-red-200" },
|
||||
certificate: { icon: Award, color: "text-emerald-600 bg-emerald-50 border-emerald-200" },
|
||||
marketing: { icon: Megaphone, color: "text-purple-600 bg-purple-50 border-purple-200" },
|
||||
other: { icon: HelpCircle, color: "text-gray-600 bg-gray-50 border-gray-200" }
|
||||
};
|
||||
|
||||
export default function AssetTypeList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, loading, fetchItems, deleteItem } = useAssetType();
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const activeCount = items.filter(i => (i.status || 'active').toLowerCase() === 'active').length;
|
||||
const inactiveCount = items.length - activeCount;
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
try {
|
||||
await deleteItem(deleteModal.id);
|
||||
setDeleteModal({ isOpen: false, id: "", name: "" });
|
||||
} catch {
|
||||
// toast error handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Asset Types" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="text-gray-700 bg-white">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<Button variant="outline" className="text-gray-700 bg-white" onClick={() => fetchItems()}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button className="bg-primary hover:bg-primary-hover text-white" onClick={() => navigate("new")}>
|
||||
@@ -134,122 +63,121 @@ export default function AssetTypeList() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatsCard
|
||||
title="Total Asset Types"
|
||||
value={12}
|
||||
value={items.length}
|
||||
subtitle="across all categories"
|
||||
color="purple"
|
||||
icon={<LayoutGrid className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Types"
|
||||
value={11}
|
||||
subtitle="1 inactive"
|
||||
value={activeCount}
|
||||
subtitle={`${inactiveCount} inactive`}
|
||||
color="green"
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Families Using"
|
||||
value={286}
|
||||
subtitle="family assignments"
|
||||
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" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Products Using"
|
||||
value="68,420"
|
||||
subtitle="product associations"
|
||||
title="Required Types"
|
||||
value={items.filter(i => i.isRequired).length}
|
||||
subtitle="enforced for completeness"
|
||||
color="orange"
|
||||
icon={<BarChart2 className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "name", label: "ASSET TYPE NAME", render: (_: any, type: any) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<div className="flex items-start gap-3 max-w-sm">
|
||||
<div className="w-8 h-8 rounded bg-blue-50 text-blue-600 flex items-center justify-center mt-1 shrink-0">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{type.name}</span>
|
||||
{type.required && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase rounded bg-red-50 text-red-600">Required</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 truncate max-w-xs">{type.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "name", label: "ASSET TYPE NAME", render: (_: any, type: AssetType) => {
|
||||
const catKey = (type.category || 'other').toLowerCase();
|
||||
const CatInfo = CATEGORY_MAP[catKey] || CATEGORY_MAP.other;
|
||||
const Icon = CatInfo.icon;
|
||||
const isReq = type.isRequired;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 max-w-sm">
|
||||
<div className="w-8 h-8 rounded bg-blue-50 text-blue-600 flex items-center justify-center mt-1 shrink-0">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "code", label: "CODE", render: (val: string) => (
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">{val}</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "category", label: "CATEGORY", render: (val: string, type: any) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${type.categoryColor}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{val}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "fileTypes", label: "FILE TYPES", render: (val: string[]) => (
|
||||
<div className="flex flex-wrap gap-1 w-32">
|
||||
{val.map((ft, i) => (
|
||||
<span key={i} className="text-[10px] font-bold text-gray-600 uppercase">
|
||||
{ft}{i < val.length - 1 && ' '}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{type.name}</span>
|
||||
{isReq && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase rounded bg-red-50 text-red-600 border border-red-200">Required</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 truncate max-w-xs">{type.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "code", label: "CODE", render: (val: string) => (
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">{val || '—'}</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "category", label: "CATEGORY", render: (val: string) => {
|
||||
const catKey = (val || 'other').toLowerCase();
|
||||
const CatInfo = CATEGORY_MAP[catKey] || CATEGORY_MAP.other;
|
||||
const Icon = CatInfo.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${CatInfo.color}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{val ? val.toUpperCase() : 'OTHER'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "allowedFileTypes", label: "ALLOWED FILE TYPES", render: (_: any, type: AssetType) => {
|
||||
const types = type.validation?.allowedFileTypes || [];
|
||||
if (types.length === 0) return <span className="text-xs text-gray-400">All types</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-[160px]">
|
||||
{types.map((ft, i) => (
|
||||
<span key={i} className="text-[10px] font-bold bg-gray-100 border border-gray-200 text-gray-700 px-1.5 py-0.5 rounded uppercase">
|
||||
{ft}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "status", label: "STATUS", render: (val: string) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-xs font-medium text-emerald-700">{val}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "families", label: "FAMILIES", render: (val: string) => <span className="font-medium text-gray-900">{val}</span>
|
||||
},
|
||||
{
|
||||
key: "products", label: "PRODUCTS", render: (val: string) => <span className="font-medium text-gray-900">{val}</span>
|
||||
},
|
||||
{
|
||||
key: "updatedDate", label: "UPDATED", render: (val: string, type: any) => (
|
||||
<div>
|
||||
<div className="text-xs text-gray-900">{val}</div>
|
||||
<div className="text-[11px] text-gray-500">{type.updatedBy}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
]}
|
||||
data={MOCK_ASSET_TYPES}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`/settings/asset-types/${row.id}`),
|
||||
onEdit: (row) => navigate(`/settings/asset-types/${row.id}`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
/>
|
||||
|
||||
},
|
||||
{
|
||||
key: "status", label: "STATUS", render: (val: string) => {
|
||||
const statusStr = (val || 'active').toLowerCase();
|
||||
const isAct = statusStr === 'active';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${isAct ? 'bg-emerald-500' : 'bg-gray-400'}`}></div>
|
||||
<span className={`text-xs font-medium ${isAct ? 'text-emerald-700' : 'text-gray-600'}`}>{isAct ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
]}
|
||||
data={items}
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`/asset-types/${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Asset Type"
|
||||
description="Are you sure you want to delete this asset type? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -10,28 +10,27 @@ interface ApiResponse<T> {
|
||||
|
||||
export const assetTypesService = {
|
||||
getAll: async (): Promise<AssetType[]> => {
|
||||
// Assuming backend maps this to /api/v1/media/asset-types
|
||||
const res = await apiClient.get<ApiResponse<AssetType[]>>('/api/v1/media/asset-types');
|
||||
const res = await apiClient.get<ApiResponse<AssetType[]>>('/api/v1/asset-types');
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<AssetType | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetType>>(`/api/v1/media/asset-types/${id}`);
|
||||
const res = await apiClient.get<ApiResponse<AssetType>>(`/api/v1/asset-types/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
create: async (req: AssetTypeCreateRequest): Promise<AssetType> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetType>>('/api/v1/media/asset-types', req);
|
||||
const res = await apiClient.post<ApiResponse<AssetType>>('/api/v1/asset-types', req);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: AssetTypeUpdateRequest): Promise<AssetType> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetType>>(`/api/v1/media/asset-types/${id}`, req);
|
||||
const res = await apiClient.put<ApiResponse<AssetType>>(`/api/v1/asset-types/${id}`, req);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/media/asset-types/${id}`);
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/asset-types/${id}`);
|
||||
return res.success;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -7,13 +8,17 @@ export const useAttributeGroup = () => {
|
||||
const [items, setItems] = useState<AttributeGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
const fetchItems = useCallback(async (query?: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await attributeGroupsService.getAll();
|
||||
const data = await attributeGroupsService.getAll(query);
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
notify.error(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);
|
||||
}
|
||||
@@ -24,10 +29,14 @@ export const useAttributeGroup = () => {
|
||||
try {
|
||||
const created = await attributeGroupsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
notify.success('Attribute group created successfully!');
|
||||
notify.success('Attribute group created successfully!');
|
||||
return created;
|
||||
} catch (err) {
|
||||
notify.error(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);
|
||||
@@ -39,10 +48,14 @@ export const useAttributeGroup = () => {
|
||||
try {
|
||||
const updated = await attributeGroupsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
notify.success('Attribute group updated successfully!');
|
||||
notify.success('Attribute group updated successfully!');
|
||||
return updated;
|
||||
} catch (err) {
|
||||
notify.error(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);
|
||||
@@ -54,9 +67,13 @@ export const useAttributeGroup = () => {
|
||||
try {
|
||||
await attributeGroupsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
notify.success('Attribute group deleted successfully!');
|
||||
notify.success('Attribute group deleted successfully!');
|
||||
} catch (err) {
|
||||
notify.error(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,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 { Plus, Save, Info, Check } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
@@ -10,13 +10,12 @@ import { attributeGroupSchema } from "../validation/attribute-groups.schema";
|
||||
import type { AttributeGroupCreateRequest } from "../types/attribute-groups.types";
|
||||
import { useAttribute } from "../../attributes/hook/useAttribute";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { generateCodeFromName } from "../../../utils/validators";
|
||||
|
||||
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) =>
|
||||
@@ -39,7 +38,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();
|
||||
|
||||
@@ -51,7 +52,8 @@ export default function NewAttributeGroup() {
|
||||
initialValues: { code: "", name: "", description: "", status: "draft" },
|
||||
validationSchema: attributeGroupSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const payload: any = { code: values.code, name: values.name, status: values.status };
|
||||
const generatedCode = values.code.trim() || values.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
const payload: any = { code: generatedCode, name: values.name.trim(), status: values.status };
|
||||
if (values.description?.trim()) payload.description = values.description.trim();
|
||||
if (selectedAttributes.length > 0) payload.attributes = selectedAttributes.map((a) => a.id);
|
||||
try {
|
||||
@@ -69,13 +71,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 +92,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()) ||
|
||||
@@ -110,12 +112,16 @@ export default function NewAttributeGroup() {
|
||||
return (
|
||||
<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 +148,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 +175,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 +186,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" && (
|
||||
@@ -211,8 +216,10 @@ export default function NewAttributeGroup() {
|
||||
value={formik.values.name}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
if (!isEdit) {
|
||||
formik.setFieldValue("code", generateCodeFromName(e.target.value));
|
||||
const val = e.target.value;
|
||||
if (!isEdit && (!formik.values.code || formik.values.code === formik.values.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''))) {
|
||||
const autoCode = val.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
formik.setFieldValue('code', autoCode);
|
||||
}
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
@@ -366,8 +373,9 @@ export default function NewAttributeGroup() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
{activeIndex > 0 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ interface ApiResponse<T> {
|
||||
}
|
||||
|
||||
export const attributeGroupsService = {
|
||||
getAll: async (): Promise<AttributeGroup[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AttributeGroup[]>>('/api/v1/attribute-groups');
|
||||
getAll: async (params?: Record<string, any>): Promise<AttributeGroup[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AttributeGroup[]>>('/api/v1/attribute-groups', { params });
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
|
||||
@@ -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 { Plus, Save, Info, Check } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
@@ -39,7 +39,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();
|
||||
|
||||
@@ -75,7 +77,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({
|
||||
@@ -90,7 +92,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()) ||
|
||||
@@ -110,12 +112,16 @@ export default function NewAttributeSet() {
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -182,7 +188,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" && (
|
||||
@@ -364,8 +371,9 @@ export default function NewAttributeSet() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
{activeIndex > 0 && (
|
||||
|
||||
@@ -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,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 { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
@@ -29,7 +29,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 +83,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 +98,26 @@ export default function NewBrand() {
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, id]);
|
||||
}, [isEdit, isView, id]);
|
||||
|
||||
return (
|
||||
<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 +198,8 @@ export default function NewBrand() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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,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 {
|
||||
@@ -59,7 +59,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 +93,7 @@ export default function NewChannel() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
if ((isEdit || isView) && id) {
|
||||
channelsService.getById(id).then((channel) => {
|
||||
if (channel) {
|
||||
formik.setValues({
|
||||
@@ -106,7 +108,7 @@ export default function NewChannel() {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [id, isEdit]);
|
||||
}, [id, isEdit, isView]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.handleChange(e);
|
||||
@@ -120,14 +122,18 @@ export default function NewChannel() {
|
||||
return (
|
||||
<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 +200,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 +396,9 @@ export default function NewChannel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
{activeIndex > 0 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface Family {
|
||||
workflowCode?: string;
|
||||
attributeSetId?: string;
|
||||
attribute_set_id?: string;
|
||||
allowedBrands?: any[];
|
||||
allowedUnits?: any[];
|
||||
attributeSet?: any;
|
||||
}
|
||||
|
||||
export type FamilyCreateRequest = Omit<Family, 'id' | 'lastUpdated' | 'createdBy'>;
|
||||
|
||||
@@ -9,4 +9,12 @@ export const familySchema = Yup.object().shape({
|
||||
attributes: Yup.array().of(Yup.string()),
|
||||
variantAxes: Yup.array().of(Yup.string()),
|
||||
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
|
||||
allowedBrands: Yup.array()
|
||||
.of(Yup.string())
|
||||
.min(1, 'At least one Brand is required')
|
||||
.required('At least one Brand is required'),
|
||||
allowedUnits: Yup.array()
|
||||
.of(Yup.string())
|
||||
.min(1, 'At least one Unit is required')
|
||||
.required('At least one Unit is required'),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -33,9 +33,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 +74,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 +100,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 +180,7 @@ export default function NewIntegration() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
if ((isEdit || isView) && id) {
|
||||
setLoadingItem(true);
|
||||
integrationsService.getById(id).then((item) => {
|
||||
if (item) {
|
||||
@@ -224,7 +227,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;
|
||||
@@ -271,19 +274,25 @@ export default function NewIntegration() {
|
||||
return (
|
||||
<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 +335,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 +976,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 +990,7 @@ export default function NewIntegration() {
|
||||
|
||||
{/* Right: Integration Summary */}
|
||||
<IntegrationSummary
|
||||
disabled={isView}
|
||||
name={formik.values.name}
|
||||
channel={formik.values.channel}
|
||||
channelName={selectedChanName}
|
||||
|
||||
@@ -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,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useFormik } from 'formik';
|
||||
import { productService } from '../services/product.service';
|
||||
import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw, Check } from 'lucide-react';
|
||||
import { useFamily } from '../../family/hook/useFamily';
|
||||
import { workflowService } from '../../workflow/services/workflow.service';
|
||||
import { StageTimeline } from '../../workflow/components/StageTimeline';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw, Check, Plus } from 'lucide-react';
|
||||
import { PageWrapper } from '../../../components/layouts/PageWrapper';
|
||||
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
|
||||
import { productSchema } from '../validation/product.schema';
|
||||
@@ -10,62 +14,64 @@ import { FamilyCard } from '../../../components/customs/FamilyCard';
|
||||
import { Radio, RadioGroup } from '../../../components/customs/Radio';
|
||||
import { Select } from '../../../components/customs/Select';
|
||||
|
||||
|
||||
const MOCK_FAMILIES = [
|
||||
{
|
||||
id: 'f1',
|
||||
name: 'Professional Laptops',
|
||||
code: 'PROF-LAPTOP',
|
||||
desc: 'Enterprise-grade computing devices with advanced specifications',
|
||||
axes: 3, attrGroups: 3, channels: 4, workflow: 'Standard Approval'
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
name: 'Wireless Headphones',
|
||||
code: 'AUDIO-WIRELESS',
|
||||
desc: 'Premium audio devices with advanced connectivity',
|
||||
axes: 2, attrGroups: 2, channels: 3, workflow: 'Fast Track'
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
name: 'Smart Home Devices',
|
||||
code: 'SMART-HOME',
|
||||
desc: 'IoT-enabled home automation products',
|
||||
axes: 2, attrGroups: 2, channels: 2, workflow: 'Standard Approval'
|
||||
}
|
||||
];
|
||||
|
||||
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() {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const { families, fetchFamilies, loading: familiesLoading } = useFamily();
|
||||
|
||||
useEffect(() => {
|
||||
fetchFamilies();
|
||||
}, [fetchFamilies]);
|
||||
|
||||
const realFamiliesList = useMemo(() => {
|
||||
return families.map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
desc: f.description || 'Configured Product Family',
|
||||
axes: f.variantAxisCount ?? (Array.isArray(f.variantAxes) ? f.variantAxes.length : 0),
|
||||
attrGroups: f.attributeGroups ?? (Array.isArray(f.attributes) ? f.attributes.length : 0),
|
||||
channels: f.channelCount ?? (Array.isArray(f.channels) ? f.channels.length : 0),
|
||||
workflow: f.workflowCode || 'Standard Approval',
|
||||
allowedBrands: f.allowedBrands || [],
|
||||
allowedUnits: f.allowedUnits || []
|
||||
}));
|
||||
}, [families]);
|
||||
|
||||
const [step, setStep] = useState(isEdit ? 2 : 1);
|
||||
const [creationMethod, setCreationMethod] = useState<'family' | 'clone'>('family');
|
||||
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
const [inheritedWorkflow, setInheritedWorkflow] = useState<any | null>(null);
|
||||
const [workflowStages, setWorkflowStages] = useState<any[]>([]);
|
||||
|
||||
const selectedFamilyObj = useMemo(() => {
|
||||
return realFamiliesList.find(f => f.id === selectedFamily);
|
||||
}, [realFamiliesList, selectedFamily]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
sku: '',
|
||||
productId: 'PROF-LAPTOP',
|
||||
productId: '',
|
||||
category: '',
|
||||
subcategory: '',
|
||||
status: 'draft',
|
||||
price: '',
|
||||
stock: 0,
|
||||
code: 'PROF-LAPTOP',
|
||||
code: '',
|
||||
barcode: '',
|
||||
gtin: '',
|
||||
upc: '',
|
||||
@@ -75,25 +81,105 @@ export default function NewProduct() {
|
||||
country: '',
|
||||
hsn: '',
|
||||
type: 'simple',
|
||||
shortDesc: ''
|
||||
shortDesc: '',
|
||||
metadata: {
|
||||
workflowCode: '',
|
||||
workflowName: '',
|
||||
currentStage: 'draft'
|
||||
} as any
|
||||
},
|
||||
validationSchema: productSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await productService.update(id, values as any);
|
||||
toast.success("Product updated successfully!");
|
||||
} else {
|
||||
await productService.create(values as any);
|
||||
await productService.create({ ...values, family_id: selectedFamily } as any);
|
||||
toast.success("Product created successfully!");
|
||||
}
|
||||
navigate('/products');
|
||||
} catch {
|
||||
// handled by service
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to save product');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load product & workflow details in edit mode
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
const productData = await productService.getById(id);
|
||||
if (productData) {
|
||||
formik.setValues({
|
||||
name: productData.name || '',
|
||||
sku: productData.sku || '',
|
||||
productId: productData.productId || productData.code || '',
|
||||
category: productData.category_id || productData.category || '',
|
||||
subcategory: productData.subcategory || '',
|
||||
status: productData.status || 'draft',
|
||||
price: productData.price || '',
|
||||
stock: productData.stock || 0,
|
||||
code: productData.code || '',
|
||||
barcode: productData.barcode || '',
|
||||
gtin: productData.gtin || '',
|
||||
upc: productData.upc || '',
|
||||
ean: productData.ean || '',
|
||||
brand: productData.brand_id || productData.brand || '',
|
||||
unit: productData.unit_id || productData.unit || '',
|
||||
country: productData.country || '',
|
||||
hsn: productData.hsn || '',
|
||||
type: productData.type || 'simple',
|
||||
shortDesc: productData.shortDesc || '',
|
||||
metadata: productData.metadata || {
|
||||
workflowCode: '',
|
||||
workflowName: '',
|
||||
currentStage: 'draft'
|
||||
}
|
||||
});
|
||||
setSelectedFamily(productData.family_id || (productData.family ? productData.family.id : null));
|
||||
|
||||
const meta = productData.metadata || {};
|
||||
const wfCode = meta.workflowCode || meta.workflow_code;
|
||||
if (wfCode) {
|
||||
const workflows = await workflowService.getAll();
|
||||
const matchingWf = workflows.find(w => w.code === wfCode);
|
||||
if (matchingWf) {
|
||||
setInheritedWorkflow(matchingWf);
|
||||
setWorkflowStages(matchingWf.stages || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to load product details');
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
loadProduct();
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
// Reset brand/unit selections if they are not allowed by the newly selected family
|
||||
useEffect(() => {
|
||||
if (selectedFamilyObj) {
|
||||
const allowedBrands = selectedFamilyObj.allowedBrands || [];
|
||||
const allowedUnits = selectedFamilyObj.allowedUnits || [];
|
||||
|
||||
const currentBrand = formik.values.brand;
|
||||
const currentUnit = formik.values.unit;
|
||||
|
||||
if (currentBrand && !allowedBrands.some((b: any) => b.id === currentBrand)) {
|
||||
formik.setFieldValue('brand', '');
|
||||
}
|
||||
if (currentUnit && !allowedUnits.some((u: any) => u.id === currentUnit)) {
|
||||
formik.setFieldValue('unit', '');
|
||||
}
|
||||
}
|
||||
}, [selectedFamily, selectedFamilyObj]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && selectedFamily) {
|
||||
setStep(2);
|
||||
@@ -112,7 +198,7 @@ export default function NewProduct() {
|
||||
step === 2 ? (
|
||||
<>
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-primary-dark bg-primary/5 border border-primary/20 rounded-md">
|
||||
{MOCK_FAMILIES.find(f => f.id === selectedFamily)?.name}
|
||||
{selectedFamilyObj?.name || 'Selected Family'}
|
||||
</span>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Save className="w-4 h-4" /> Save Draft
|
||||
@@ -128,10 +214,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'}`}
|
||||
>
|
||||
@@ -143,8 +229,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'}`}
|
||||
>
|
||||
@@ -161,16 +247,34 @@ export default function NewProduct() {
|
||||
{creationMethod === 'family' && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-3">Product Family</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{MOCK_FAMILIES.map(family => (
|
||||
<FamilyCard
|
||||
key={family.id}
|
||||
family={family}
|
||||
selected={selectedFamily === family.id}
|
||||
onSelect={(id) => { setSelectedFamily(id); handleNext(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{familiesLoading && realFamiliesList.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Loading Product Families...
|
||||
</div>
|
||||
) : realFamiliesList.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{realFamiliesList.map(family => (
|
||||
<FamilyCard
|
||||
key={family.id}
|
||||
family={family}
|
||||
selected={selectedFamily === family.id}
|
||||
onSelect={(id) => { setSelectedFamily(id); handleNext(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 border-2 border-dashed border-gray-200 rounded-xl text-center bg-white">
|
||||
<Box className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<h3 className="font-medium text-gray-900 mb-1">No Product Families Found</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Please create a Product Family first to generate products from a family blueprint.</p>
|
||||
<button
|
||||
onClick={() => navigate('/families/new')}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Create Product Family
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -179,9 +283,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>
|
||||
@@ -213,19 +317,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" />
|
||||
@@ -241,9 +344,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>
|
||||
);
|
||||
@@ -266,10 +368,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: '3' },
|
||||
{ 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>
|
||||
@@ -282,7 +384,71 @@ export default function NewProduct() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="overflow-y-auto">
|
||||
{isEdit && inheritedWorkflow && (
|
||||
<div className="mb-4 bg-white border border-gray-200 rounded-xl p-5 shadow-2xs mr-1">
|
||||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
|
||||
<div>
|
||||
<span className="text-[9px] font-bold text-gray-400 block uppercase font-mono">Lifecycle Workflow</span>
|
||||
<h4 className="font-semibold text-gray-900 text-sm">{inheritedWorkflow.name} ({inheritedWorkflow.category})</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-right">
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-500">Current Stage: </span>
|
||||
<span className="font-bold text-primary-dark uppercase">
|
||||
{workflowStages.find(s => s.code === (formik.values as any).metadata?.currentStage)?.name || (formik.values as any).metadata?.currentStage || 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Promote button */}
|
||||
{(() => {
|
||||
const currentStageCode = (formik.values as any).metadata?.currentStage || 'draft';
|
||||
const sorted = [...workflowStages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
|
||||
const currentIdx = sorted.findIndex(s => s.code === currentStageCode);
|
||||
if (currentIdx !== -1 && currentIdx < sorted.length - 1) {
|
||||
const nextStage = sorted[currentIdx + 1];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const updatedMetadata = {
|
||||
...(formik.values as any).metadata,
|
||||
currentStage: nextStage.code
|
||||
};
|
||||
await productService.update(id!, {
|
||||
...formik.values,
|
||||
metadata: updatedMetadata
|
||||
} as any);
|
||||
formik.setFieldValue('metadata', updatedMetadata);
|
||||
toast.success(`Product advanced to stage: ${nextStage.name}`);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to advance stage');
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white text-[11px] font-bold rounded-lg transition-all shadow-2xs"
|
||||
>
|
||||
Promote to {nextStage.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-150">
|
||||
Final Stage Completed
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2 overflow-x-auto">
|
||||
<StageTimeline
|
||||
stages={workflowStages}
|
||||
currentStageCode={(formik.values as any).metadata?.currentStage || 'draft'}
|
||||
variant="horizontal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-y-auto">
|
||||
{activeTab === 'general' && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-8">
|
||||
{/* Basic Information */}
|
||||
@@ -294,12 +460,12 @@ export default function NewProduct() {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Product Name *</label>
|
||||
<input
|
||||
<input
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="Enter product name"
|
||||
className={inputClass}
|
||||
placeholder="Enter product name"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -367,11 +533,31 @@ export default function NewProduct() {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Brand</label>
|
||||
<Select><option>Select brand</option></Select>
|
||||
<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><option>Select unit</option></Select>
|
||||
<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>
|
||||
@@ -637,7 +823,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">
|
||||
<div>
|
||||
@@ -648,7 +834,7 @@ export default function NewProduct() {
|
||||
<FolderTree className="w-4 h-4" /> Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="border border-gray-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-900 mb-1">POS Tree</div>
|
||||
@@ -687,7 +873,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">
|
||||
{[
|
||||
{ name: 'Shopify', status: 'Ready for publishing', complete: '4%' },
|
||||
@@ -831,7 +1017,7 @@ export default function NewProduct() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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 { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
@@ -32,7 +32,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();
|
||||
|
||||
@@ -77,7 +79,7 @@ export default function NewUnit() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
if ((isEdit || isView) && id) {
|
||||
unitService.getById(id).then((unit) => {
|
||||
if (unit) {
|
||||
setIsActive(unit.status === 'active');
|
||||
@@ -94,23 +96,26 @@ export default function NewUnit() {
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, id]);
|
||||
}, [isEdit, isView, id]);
|
||||
|
||||
return (
|
||||
<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" />
|
||||
@@ -238,7 +243,8 @@ export default function NewUnit() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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,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 { PageWrapper } from '../../../components/layouts/PageWrapper';
|
||||
import { Button } from '../../../components/customs/Button';
|
||||
@@ -40,7 +40,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);
|
||||
@@ -181,14 +183,18 @@ export default function NewVariant() {
|
||||
return (
|
||||
<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 +271,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 +575,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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import type { WorkflowStage } from '../types/workflow.types';
|
||||
import { Check, Circle } from 'lucide-react';
|
||||
|
||||
interface StageTimelineProps {
|
||||
stages: WorkflowStage[];
|
||||
currentStageCode?: string;
|
||||
variant?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<string, { bg: string; text: string; border: string; dot: string }> = {
|
||||
slate: { bg: 'bg-slate-50', text: 'text-slate-700', border: 'border-slate-200', dot: 'bg-slate-500' },
|
||||
blue: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', dot: 'bg-blue-600' },
|
||||
yellow: { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200', dot: 'bg-amber-500' },
|
||||
orange: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', dot: 'bg-orange-500' },
|
||||
emerald: { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200', dot: 'bg-emerald-600' },
|
||||
green: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200', dot: 'bg-green-600' },
|
||||
indigo: { bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-200', dot: 'bg-indigo-600' },
|
||||
purple: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200', dot: 'bg-purple-600' },
|
||||
red: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', dot: 'bg-red-600' },
|
||||
};
|
||||
|
||||
export const StageTimeline: React.FC<StageTimelineProps> = ({
|
||||
stages = [],
|
||||
currentStageCode,
|
||||
variant = 'horizontal',
|
||||
}) => {
|
||||
const sortedStages = [...stages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
|
||||
|
||||
// Determine current stage index
|
||||
const currentIndex = sortedStages.findIndex(
|
||||
(s) => s.code === currentStageCode || s.name === currentStageCode
|
||||
);
|
||||
|
||||
const getStageStatus = (index: number) => {
|
||||
if (currentIndex === -1) return 'pending';
|
||||
if (index < currentIndex) return 'completed';
|
||||
if (index === currentIndex) return 'active';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
if (sortedStages.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-400 italic py-2">
|
||||
No stages defined for this workflow.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'vertical') {
|
||||
return (
|
||||
<div className="relative border-l-2 border-gray-200 ml-4 pl-6 space-y-6 py-2">
|
||||
{sortedStages.map((stage, idx) => {
|
||||
const status = getStageStatus(idx);
|
||||
const colorKey = stage.color || 'slate';
|
||||
const colors = COLOR_MAP[colorKey] || COLOR_MAP.slate;
|
||||
|
||||
return (
|
||||
<div key={stage.code || idx} className="relative group">
|
||||
{/* Timeline Indicator Dot */}
|
||||
<div
|
||||
className={`absolute -left-[33px] top-1.5 w-4 h-4 rounded-full border-2 bg-white flex items-center justify-center transition-all ${
|
||||
status === 'completed'
|
||||
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||
: status === 'active'
|
||||
? 'border-primary bg-primary scale-125 ring-4 ring-primary/20'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{status === 'completed' ? (
|
||||
<Check className="w-2.5 h-2.5 text-white stroke-[3]" />
|
||||
) : status === 'active' ? (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Stage Card */}
|
||||
<div
|
||||
className={`p-4 rounded-xl border transition-all ${
|
||||
status === 'active'
|
||||
? 'border-primary/20 bg-primary/5 shadow-xs'
|
||||
: 'border-gray-150 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-gray-400">#{stage.order}</span>
|
||||
<h4 className="font-semibold text-gray-900 text-sm">{stage.name}</h4>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${colors.bg} ${colors.text} border ${colors.border}`}
|
||||
>
|
||||
{stage.code}
|
||||
</span>
|
||||
</div>
|
||||
{stage.description && (
|
||||
<p className="text-xs text-gray-500 mt-2 leading-relaxed">
|
||||
{stage.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal Layout
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-start justify-between relative">
|
||||
{/* Connection track background line */}
|
||||
<div className="absolute top-4 left-0 right-0 h-0.5 bg-gray-200 -z-10" />
|
||||
|
||||
{sortedStages.map((stage, idx) => {
|
||||
const status = getStageStatus(idx);
|
||||
const colorKey = stage.color || 'slate';
|
||||
const colors = COLOR_MAP[colorKey] || COLOR_MAP.slate;
|
||||
|
||||
return (
|
||||
<div key={stage.code || idx} className="flex flex-col items-center flex-1 relative px-2">
|
||||
{/* Step indicator node */}
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full border-2 bg-white flex items-center justify-center z-10 transition-all ${
|
||||
status === 'completed'
|
||||
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||
: status === 'active'
|
||||
? 'border-primary bg-primary ring-4 ring-primary/10'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === 'completed' ? (
|
||||
<Check className="w-4 h-4 text-white stroke-[3]" />
|
||||
) : status === 'active' ? (
|
||||
<Circle className="w-4 h-4 text-white fill-white animate-pulse" />
|
||||
) : (
|
||||
<span className="text-xs font-bold text-gray-400">{stage.order}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info section below dot */}
|
||||
<div className="text-center mt-3 max-w-[120px]">
|
||||
<div className="font-semibold text-gray-900 text-xs truncate" title={stage.name}>
|
||||
{stage.name}
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block mt-1 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-wider ${colors.bg} ${colors.text} border ${colors.border}`}
|
||||
>
|
||||
{stage.code}
|
||||
</span>
|
||||
{stage.description && (
|
||||
<div className="text-[10px] text-gray-400 mt-1 line-clamp-2 leading-tight" title={stage.description}>
|
||||
{stage.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -63,5 +63,34 @@ export const useWorkflow = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
const archiveItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await workflowService.archive(id);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? { ...p, status: 'archived', deletedAt: new Date().toISOString() } : p)));
|
||||
notify.success('Workflow archived successfully!');
|
||||
} catch (err: any) {
|
||||
notify.error(err.message || 'Failed to archive item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const restored = await workflowService.restore(id);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? restored : p)));
|
||||
notify.success('Workflow restored successfully!');
|
||||
return restored;
|
||||
} catch (err: any) {
|
||||
notify.error(err.message || 'Failed to restore item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem, archiveItem, restoreItem };
|
||||
};
|
||||
|
||||
@@ -1,112 +1,754 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik, FormikProvider } from "formik";
|
||||
import { workflowService } from "../services/workflow.service";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { Save, Check } from "lucide-react";
|
||||
import { Label } from "../../../components/customs/Label";
|
||||
import { Input } from "../../../components/customs/Input";
|
||||
import { TextArea } from "../../../components/customs/TextArea";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { FormikProvider, useFormik } from "formik";
|
||||
import { StageTimeline } from "../components/StageTimeline";
|
||||
import { toast } from "react-toastify";
|
||||
import { Plus, Trash2, Edit2, ArrowUp, ArrowDown, Check, Layers, AlertCircle, Save } from "lucide-react";
|
||||
import { workflowSchema } from "../validation/workflow.schema";
|
||||
|
||||
import { GeneralInfoTab } from "../components/GeneralInfoTab";
|
||||
import { WorkflowStagesTab } from "../components/WorkflowStagesTab";
|
||||
import { StageTransitionsTab } from "../components/StageTransitionsTab";
|
||||
import { RolePermissionsTab } from "../components/RolePermissionsTab";
|
||||
import { NotificationsTab } from "../components/NotificationsTab";
|
||||
import { AuditUsageTab } from "../components/AuditUsageTab";
|
||||
import { WorkflowOverview } from "../components/WorkflowOverview";
|
||||
|
||||
import type { WorkflowStage } from "../types/workflow.types";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
const STEPS = [
|
||||
{ step: 1, label: "Basic Details" },
|
||||
{ step: 2, label: "Stages Designer" },
|
||||
{ step: 3, label: "Visual Path Preview" },
|
||||
{ step: 4, label: "Summary & Submit" }
|
||||
];
|
||||
|
||||
const PRESET_COLORS = ["slate", "blue", "yellow", "orange", "emerald", "green", "indigo", "purple", "red"];
|
||||
|
||||
const DEFAULT_STAGES: WorkflowStage[] = [
|
||||
{ name: "Draft", code: "draft", order: 1, color: "slate", description: "Initial draft stage of product creation", actions: [] },
|
||||
{ name: "Technical Review", code: "technical_review", order: 2, color: "blue", description: "Technical specification and compliance check", actions: [] },
|
||||
{ name: "QA Review", code: "qa_review", order: 3, color: "yellow", description: "Quality assurance validation", actions: [] },
|
||||
{ name: "Marketing Review", code: "marketing_review", order: 4, color: "orange", description: "Marketing copy and media validation", actions: [] },
|
||||
{ name: "Approved", code: "approved", order: 5, color: "emerald", description: "Workflow review complete and approved", actions: [] },
|
||||
{ name: "Published", code: "published", order: 6, color: "indigo", description: "Product catalog publishing stage", actions: [] }
|
||||
];
|
||||
|
||||
export default function NewWorkflow() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const [activeTab, setActiveTab] = useState("General Information");
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Dynamic Stage Modals / Inlines
|
||||
const [editingStageIdx, setEditingStageIdx] = useState<number | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [newStage, setNewStage] = useState<WorkflowStage>({
|
||||
name: "",
|
||||
code: "",
|
||||
order: 1,
|
||||
color: "slate",
|
||||
description: "",
|
||||
actions: []
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
category: 'Standard Approval',
|
||||
status: 'draft'
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
category: "Standard Approval",
|
||||
status: "active" as 'active' | 'draft' | 'inactive' | 'archived',
|
||||
stages: DEFAULT_STAGES as WorkflowStage[]
|
||||
},
|
||||
validationSchema: workflowSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
onSubmit: async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await workflowService.update(id, values as any);
|
||||
await workflowService.update(id, values);
|
||||
notify.success("Workflow updated successfully!");
|
||||
} else {
|
||||
await workflowService.create(values as any);
|
||||
await workflowService.create(values);
|
||||
notify.success("Workflow created successfully!");
|
||||
}
|
||||
navigate('/workflows');
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || "Failed to save workflow";
|
||||
navigate("/workflow");
|
||||
} 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 {
|
||||
setSubmitting(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tabs = ["General Information", "Workflow Stages", "Stage Transitions", "Role Permissions", "Notifications", "Audit & Usage"];
|
||||
// Automatically slugify name into code if code is not typed manually
|
||||
useEffect(() => {
|
||||
if (!isEdit && formik.values.name && !formik.values.code) {
|
||||
const slug = formik.values.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
formik.setFieldValue("code", slug);
|
||||
}
|
||||
}, [formik.values.name, isEdit]);
|
||||
|
||||
// Load workflow in Edit mode
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
const fetchWorkflow = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await workflowService.getById(id);
|
||||
if (data) {
|
||||
formik.setValues({
|
||||
name: data.name || "",
|
||||
code: data.code || "",
|
||||
description: data.description || "",
|
||||
category: data.category || "Standard Approval",
|
||||
status: data.status || "draft",
|
||||
stages: Array.isArray(data.stages) ? data.stages : []
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { message?: string };
|
||||
toast.error(error.message || "Failed to load workflow details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchWorkflow();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// Stage Helpers
|
||||
const handleAddStage = () => {
|
||||
if (!newStage.name || !newStage.code) {
|
||||
toast.warning("Stage Name and Code are required!");
|
||||
return;
|
||||
}
|
||||
const slugifiedCode = newStage.code.toLowerCase().replace(/[^a-z0-9_]+/g, "_");
|
||||
// Check if code duplicate
|
||||
const exists = formik.values.stages.some(s => s.code === slugifiedCode);
|
||||
if (exists) {
|
||||
toast.warning(`Stage Code "${slugifiedCode}" already exists in this workflow.`);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
toast.success("Stage added!");
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteStage = (idx: number) => {
|
||||
const stagesCopy = formik.values.stages.filter((_, i) => i !== idx);
|
||||
// Re-index orders
|
||||
const reordered = stagesCopy.map((s, i) => ({ ...s, order: i + 1 }));
|
||||
formik.setFieldValue("stages", reordered);
|
||||
toast.info("Stage removed.");
|
||||
};
|
||||
|
||||
const moveStage = (idx: number, direction: "up" | "down") => {
|
||||
const stagesCopy = [...formik.values.stages];
|
||||
if (direction === "up" && idx > 0) {
|
||||
const temp = stagesCopy[idx];
|
||||
stagesCopy[idx] = stagesCopy[idx - 1];
|
||||
stagesCopy[idx - 1] = temp;
|
||||
} else if (direction === "down" && idx < stagesCopy.length - 1) {
|
||||
const temp = stagesCopy[idx];
|
||||
stagesCopy[idx] = stagesCopy[idx + 1];
|
||||
stagesCopy[idx + 1] = temp;
|
||||
}
|
||||
// Re-index orders
|
||||
const reordered = stagesCopy.map((s, i) => ({ ...s, order: i + 1 }));
|
||||
formik.setFieldValue("stages", reordered);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1) {
|
||||
if (!formik.values.name || !formik.values.code) {
|
||||
formik.setFieldTouched("name", true);
|
||||
formik.setFieldTouched("code", true);
|
||||
toast.warning("Please complete Step 1 validations first.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (step === 2 && formik.values.stages.length === 0) {
|
||||
toast.warning("Please define at least one stage for the workflow.");
|
||||
return;
|
||||
}
|
||||
setStep(prev => Math.min(prev + 1, 4));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Workflows', href: '/workflows' }, { label: isEdit ? 'Edit Workflow' : 'New Workflow' }]}
|
||||
backTo="/workflows"
|
||||
items={[
|
||||
{ label: "Home" },
|
||||
{ label: "Workflows", href: "/workflow" },
|
||||
{ label: isEdit ? "Edit Workflow" : "Create Workflow" }
|
||||
]}
|
||||
backTo="/workflow"
|
||||
actions={
|
||||
<>
|
||||
<Select name="status" value={formik.values.status} onChange={formik.handleChange}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={() => navigate('..')}>Cancel</Button>
|
||||
<Button type="submit" form="workflow-form" className="bg-primary hover:bg-primary-hover text-white" icon={<Save className="w-4 h-4" />}>Save</Button>
|
||||
<Button type="submit" form="workflow-form" className="bg-emerald-600 hover:bg-emerald-700 text-white" icon={<Check className="w-4 h-4" />}>Publish</Button>
|
||||
</>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="bg-white" onClick={() => navigate("/workflow")}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step < 4 ? (
|
||||
<Button variant="primary" className="bg-primary text-white hover:bg-primary-hover" onClick={handleNext}>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form="workflow-wizard-form"
|
||||
disabled={loading}
|
||||
variant="primary"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white flex items-center gap-1.5"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isEdit ? "Update Workflow" : "Save & Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-gray-200 mb-6 px-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
activeTab === tab
|
||||
? 'border-primary text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-w-[1200px] mx-auto w-full flex flex-col md:flex-row gap-6 mt-2">
|
||||
{/* Wizard Sidebar Progression */}
|
||||
<aside className="w-full md:w-64 shrink-0 bg-white border border-gray-200 rounded-xl p-4 shadow-xs self-start">
|
||||
<div className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">Wizard Progress</div>
|
||||
<nav className="space-y-4">
|
||||
{STEPS.map((s) => {
|
||||
const isActive = step === s.step;
|
||||
const isCompleted = step > s.step;
|
||||
return (
|
||||
<button
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <Check className="w-3.5 h-3.5" /> : s.step}
|
||||
</div>
|
||||
<span className="text-sm">{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<FormikProvider value={formik}>
|
||||
<form id="workflow-form" onSubmit={formik.handleSubmit} className="flex gap-6 h-[calc(100vh-220px)]">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
{activeTab === "General Information" && <GeneralInfoTab />}
|
||||
{activeTab === "Workflow Stages" && <WorkflowStagesTab />}
|
||||
{activeTab === "Stage Transitions" && <StageTransitionsTab />}
|
||||
{activeTab === "Role Permissions" && <RolePermissionsTab />}
|
||||
{activeTab === "Notifications" && <NotificationsTab />}
|
||||
{activeTab === "Audit & Usage" && <AuditUsageTab />}
|
||||
{/* Wizard Panel Content */}
|
||||
<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">
|
||||
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5 to-white flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-primary-light rounded-full" />
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-sm">Step 1: Basic Information</h3>
|
||||
<p className="text-xs text-gray-500">Provide the identity, description, and status of this workflow template</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Workflow Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Electronics Approval Workflow"
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<p className="text-xs text-red-500">{String(formik.errors.name)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code">Workflow Code *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. electronics_workflow"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400">Unique code containing lowercase alphanumeric characters and underscores only.</p>
|
||||
{formik.touched.code && formik.errors.code && (
|
||||
<p className="text-xs text-red-500">{String(formik.errors.code)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="category">Workflow Category</Label>
|
||||
<Select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formik.values.category}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="Standard Approval">Standard Approval</option>
|
||||
<option value="Publishing">Publishing</option>
|
||||
<option value="Compliance">Compliance</option>
|
||||
<option value="Fast Track">Fast Track</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workflow Status</Label>
|
||||
<div className="flex gap-4">
|
||||
{["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
|
||||
? "border-primary/30 bg-primary/5 shadow-2xs"
|
||||
: "border-gray-200 hover:border-primary/10 bg-white"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value={statusOption}
|
||||
checked={formik.values.status === statusOption}
|
||||
onChange={formik.handleChange}
|
||||
className="w-4 h-4 text-primary focus:ring-primary border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-900 capitalize">{statusOption}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
rows={3}
|
||||
placeholder="Explain the purpose and lifecycle scope of this workflow..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: Stages Designer */}
|
||||
{step === 2 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-xs overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5 to-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-primary-light rounded-full" />
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-sm">Step 2: Workflow Stages Designer</h3>
|
||||
<p className="text-xs text-gray-500">Configure and order stages dynamically. First stage determines initial product stage.</p>
|
||||
</div>
|
||||
</div>
|
||||
{!showAddSection && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowAddSection(true)}
|
||||
className="bg-primary text-white hover:bg-primary-hover text-xs shadow-xs"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Add Stage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Add Stage Form Section */}
|
||||
{showAddSection && (
|
||||
<div className="bg-gray-50 border border-gray-150 p-4 rounded-xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-gray-700 uppercase tracking-wider">New Stage Details</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 text-xs font-semibold"
|
||||
onClick={() => setShowAddSection(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Stage Name *</label>
|
||||
<Input
|
||||
placeholder="e.g. Content Review"
|
||||
value={newStage.name}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setNewStage(prev => ({
|
||||
...prev,
|
||||
name: val,
|
||||
code: prev.code ? prev.code : val.toLowerCase().replace(/[^a-z0-9_]+/g, "_")
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Stage Code *</label>
|
||||
<Input
|
||||
placeholder="e.g. content_review"
|
||||
value={newStage.code}
|
||||
onChange={(e) => setNewStage(prev => ({ ...prev, code: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Theme Display Color</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
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"
|
||||
}`}
|
||||
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',
|
||||
color: '#ffffff'
|
||||
}}
|
||||
>
|
||||
{newStage.color === c && "✓"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Stage Description</label>
|
||||
<TextArea
|
||||
placeholder="Explain what needs to happen in this stage..."
|
||||
rows={2}
|
||||
value={newStage.description}
|
||||
onChange={(e) => setNewStage(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddStage}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs"
|
||||
>
|
||||
Add Stage to Definition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stages Reordering Table/List */}
|
||||
<div className="border border-gray-150 rounded-xl overflow-hidden shadow-2xs">
|
||||
{formik.values.stages && formik.values.stages.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{formik.values.stages.map((stage, idx) => {
|
||||
const isEditing = editingStageIdx === idx;
|
||||
return (
|
||||
<div key={stage.code || idx} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-gray-50/50">
|
||||
{isEditing ? (
|
||||
/* Inline editing fields */
|
||||
<div className="flex-1 grid grid-cols-2 gap-3 bg-gray-50 p-3 rounded-lg border border-gray-150">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Stage Name</label>
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={(e) => {
|
||||
const stagesCopy = [...formik.values.stages];
|
||||
stagesCopy[idx].name = e.target.value;
|
||||
formik.setFieldValue("stages", stagesCopy);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Stage Code</label>
|
||||
<Input
|
||||
value={stage.code}
|
||||
onChange={(e) => {
|
||||
const stagesCopy = [...formik.values.stages];
|
||||
stagesCopy[idx].code = e.target.value;
|
||||
formik.setFieldValue("stages", stagesCopy);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Color Theme</label>
|
||||
<div className="flex gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const stagesCopy = [...formik.values.stages];
|
||||
stagesCopy[idx].color = c;
|
||||
formik.setFieldValue("stages", stagesCopy);
|
||||
}}
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Description</label>
|
||||
<TextArea
|
||||
value={stage.description || ""}
|
||||
rows={2}
|
||||
onChange={(e) => {
|
||||
const stagesCopy = [...formik.values.stages];
|
||||
stagesCopy[idx].description = e.target.value;
|
||||
formik.setFieldValue("stages", stagesCopy);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setEditingStageIdx(null)}
|
||||
className="bg-primary text-white text-[10px] py-1"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Display fields */
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center font-bold text-xs text-gray-600 mt-1">
|
||||
{stage.order}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-gray-900">{stage.name}</span>
|
||||
<span className="text-[9px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded border border-gray-200">
|
||||
{stage.code}
|
||||
</span>
|
||||
<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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 max-w-xl">{stage.description || "No description provided."}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage Actions */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveStage(idx, "up")}
|
||||
className={`p-1.5 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
title="Move Up"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === formik.values.stages.length - 1}
|
||||
onClick={() => moveStage(idx, "down")}
|
||||
className={`p-1.5 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
title="Move Down"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingStageIdx(idx)}
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
|
||||
title="Edit Stage"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteStage(idx)}
|
||||
className="p-1.5 rounded-lg border border-red-100 bg-white text-red-500 hover:bg-red-50"
|
||||
title="Delete Stage"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-400 text-xs">
|
||||
No stages defined. Click Add Stage to define the workflow steps.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Workflow Preview */}
|
||||
{step === 3 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-xs overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5 to-white flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-primary-light rounded-full" />
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-sm">Step 3: Visual Path Preview</h3>
|
||||
<p className="text-xs text-gray-500">Read-only interactive visualization of the workflow stages lifecycle path</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-10 flex flex-col items-center">
|
||||
<div className="w-full max-w-2xl py-8 px-4 border border-gray-150 bg-gray-50/30 rounded-2xl shadow-3xs">
|
||||
<StageTimeline stages={formik.values.stages} currentStageCode="" variant="horizontal" />
|
||||
</div>
|
||||
<div className="mt-8 max-w-md text-center bg-blue-50 border border-blue-150 p-4 rounded-xl flex gap-3 text-xs text-blue-800 leading-relaxed">
|
||||
<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"
|
||||
}"</strong>. This is determined dynamically from your Stage orders.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Summary Screen */}
|
||||
{step === 4 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-xs overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5 to-white flex items-center gap-3">
|
||||
<div className="w-1.5 h-6 bg-primary-light rounded-full" />
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-sm">Step 4: Summary & Save</h3>
|
||||
<p className="text-xs text-gray-500">Verify your workflow settings before committing changes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">WORKFLOW NAME</span>
|
||||
<div className="font-bold text-sm text-gray-900 mt-1">{formik.values.name}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">WORKFLOW CODE</span>
|
||||
<div className="font-mono text-xs text-gray-600 mt-1">{formik.values.code}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">CATEGORY</span>
|
||||
<div className="font-bold text-xs text-gray-900 mt-1">{formik.values.category}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">STATUS</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge status={formik.values.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">TOTAL STAGES</span>
|
||||
<div className="font-bold text-xs text-gray-900 mt-1 flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-gray-400" />
|
||||
{formik.values.stages.length} Stages configured
|
||||
</div>
|
||||
</div>
|
||||
{formik.values.description && (
|
||||
<div className="col-span-2 bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">DESCRIPTION</span>
|
||||
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{formik.values.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</FormikProvider>
|
||||
|
||||
{/* Step navigation buttons at the bottom of panel */}
|
||||
<div className="mt-6 flex justify-between items-center bg-gray-50 p-4 rounded-xl border border-gray-200">
|
||||
{step > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="px-4 py-2 border border-gray-250 bg-white text-gray-600 rounded-lg text-sm font-semibold hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{step < 4 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="px-5 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-bold shadow-2xs transition-colors"
|
||||
>
|
||||
Next Step
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form="workflow-wizard-form"
|
||||
disabled={loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold"
|
||||
>
|
||||
{isEdit ? "Update Definition" : "Save Definition"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Workflow Overview */}
|
||||
<WorkflowOverview />
|
||||
</form>
|
||||
</FormikProvider>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Upload, Download, Activity, CheckCircle, Clock, Layers } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Activity, CheckCircle, Clock, Layers, X, Calendar, Archive, RefreshCw, Trash2, HelpCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
@@ -8,84 +8,165 @@ import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_WORKFLOWS = [
|
||||
{ id: "1", name: "Electronics Approval Workflow", desc: "Multi-stage approval process for electronics product...", code: "electronics_approval", stages: 5, families: 8, products: "4,240", pending: 23, status: "active", lastModified: "2025-03-15", author: "Sarah Chen", isDefault: false },
|
||||
{ id: "2", name: "Fashion Approval Workflow", desc: "Streamlined approval for fashion items with visual...", code: "fashion_approval", stages: 4, families: 12, products: "8,940", pending: 41, status: "active", lastModified: "2025-03-12", author: "Michael Torres", isDefault: true },
|
||||
{ id: "3", name: "Marketplace Publishing Workflow", desc: "Optimised for marketplace channels with SEO review...", code: "marketplace_publish", stages: 6, families: 5, products: "2,140", pending: 12, status: "active", lastModified: "2025-03-10", author: "Emma Wilson", isDefault: false },
|
||||
{ id: "4", name: "Compliance Review Workflow", desc: "Legal and compliance checks for restricted categories", code: "compliance_review", stages: 3, families: 2, products: "840", pending: 5, status: "active", lastModified: "2025-02-28", author: "David Park", isDefault: false },
|
||||
{ id: "5", name: "Seasonal Promotion Approval", desc: "Fast-track approval for seasonal marketing campaigns", code: "seasonal_promo", stages: 2, families: 15, products: "12,500", pending: 8, status: "draft", lastModified: "2025-02-15", author: "Lisa Wong", isDefault: false },
|
||||
];
|
||||
import { useWorkflow } from "../hook/useWorkflow";
|
||||
import { StageTimeline } from "../components/StageTimeline";
|
||||
import type { Workflow } from "../types/workflow.types";
|
||||
|
||||
export default function WorkflowList() {
|
||||
const navigate = useNavigate();
|
||||
const { items: workflows, loading, fetchItems, archiveItem, restoreItem, deleteItem } = useWorkflow();
|
||||
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [archiveModal, setArchiveModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [restoreModal, setRestoreModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// If items reload, update selected workflow inside the drawer to keep data fresh
|
||||
useEffect(() => {
|
||||
if (selectedWorkflow) {
|
||||
const refreshed = workflows.find(w => w.id === selectedWorkflow.id);
|
||||
if (refreshed) {
|
||||
setSelectedWorkflow(refreshed);
|
||||
}
|
||||
}
|
||||
}, [workflows, selectedWorkflow]);
|
||||
|
||||
// Compute live stats for KPI cards
|
||||
const stats = useMemo(() => {
|
||||
const total = workflows.length;
|
||||
const active = workflows.filter(w => w.status === 'active').length;
|
||||
const totalFamilies = workflows.reduce((sum, w) => sum + (w.familyCount || 0), 0);
|
||||
const totalProducts = workflows.reduce((sum, w) => sum + (w.productCount || 0), 0);
|
||||
return { total, active, totalFamilies, totalProducts };
|
||||
}, [workflows]);
|
||||
|
||||
const handleArchiveConfirm = async () => {
|
||||
if (!archiveModal.id) return;
|
||||
try {
|
||||
await archiveItem(archiveModal.id);
|
||||
setArchiveModal({ isOpen: false, id: "", name: "" });
|
||||
} catch {
|
||||
// toast is handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = async () => {
|
||||
if (!restoreModal.id) return;
|
||||
try {
|
||||
await restoreItem(restoreModal.id);
|
||||
setRestoreModal({ isOpen: false, id: "", name: "" });
|
||||
} catch {
|
||||
// toast is handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
try {
|
||||
await deleteItem(deleteModal.id);
|
||||
setDeleteModal({ isOpen: false, id: "", name: "" });
|
||||
if (selectedWorkflow?.id === deleteModal.id) {
|
||||
setDrawerOpen(false);
|
||||
setSelectedWorkflow(null);
|
||||
}
|
||||
} catch {
|
||||
// toast is handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = (row: Workflow) => {
|
||||
setSelectedWorkflow(row);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Workflow Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: any) => (
|
||||
render: (_: any, row: Workflow) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-primary/5 flex items-center justify-center shrink-0">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
<div className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
{row.name}
|
||||
{row.isDefault && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider bg-primary-light text-primary-dark px-1.5 py-0.5 rounded">Default</span>
|
||||
{row.deletedAt && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider bg-red-50 text-red-700 px-1.5 py-0.5 rounded border border-red-200">Archived</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{row.description || "No description provided"}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
label: "Workflow Code",
|
||||
render: (val: string) => <span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{val}</span>,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "Category",
|
||||
render: (val: string) => (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||
{val || "Standard Approval"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "stages",
|
||||
label: "Stages",
|
||||
render: (val: number) => (
|
||||
<div className="flex items-center gap-1 text-gray-700 font-medium">
|
||||
render: (_: any, row: Workflow) => (
|
||||
<div className="flex items-center gap-1 text-gray-700 font-medium text-sm">
|
||||
<Layers className="w-3.5 h-3.5 text-gray-400" />
|
||||
{val}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "families", label: "Assigned Families" },
|
||||
{ key: "products", label: "Active Products" },
|
||||
{
|
||||
key: "pending",
|
||||
label: "Pending",
|
||||
render: (val: number) => (
|
||||
<div className="inline-flex items-center justify-center gap-1.5 px-2 py-1 rounded bg-amber-50 text-amber-700 font-medium text-xs border border-amber-100/50">
|
||||
<Clock className="w-3 h-3" />
|
||||
{val}
|
||||
{row.stages ? row.stages.length : 0}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (val: any) => <StatusBadge status={val} />,
|
||||
render: (val: any, row: Workflow) => {
|
||||
if (row.deletedAt) {
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">archived</span>;
|
||||
}
|
||||
return <StatusBadge status={val} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lastModified",
|
||||
label: "Last Modified",
|
||||
render: (_: any, row: any) => (
|
||||
<div>
|
||||
<div className="text-gray-900 font-medium text-sm">{row.lastModified}</div>
|
||||
<div className="text-gray-500 text-xs mt-0.5">{row.author}</div>
|
||||
key: "usedBy",
|
||||
label: "Used By Families",
|
||||
render: (val: string[] = []) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-[180px]">
|
||||
{val && val.length > 0 ? (
|
||||
val.map((name, i) => (
|
||||
<span key={i} className="text-[10px] font-semibold bg-primary/5 text-primary-dark px-1.5 py-0.5 rounded-md border border-primary/10 truncate max-w-[120px]" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Created Date",
|
||||
render: (val: string) => (
|
||||
<span className="text-gray-600 text-xs flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-gray-400" />
|
||||
{val ? new Date(val).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -94,15 +175,10 @@ export default function WorkflowList() {
|
||||
items={[{ label: "Home" }, { label: "Workflow Management" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import Workflow
|
||||
</Button>
|
||||
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Config
|
||||
</Button>
|
||||
<Button className="bg-primary hover:bg-primary-hover text-white" onClick={() => navigate("new")}>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary-hover text-white transition-all shadow-xs"
|
||||
onClick={() => navigate("new")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
@@ -110,55 +186,222 @@ export default function WorkflowList() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards - Using your own WorkflowStatsCard */}
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatsCard
|
||||
title="Total Workflows"
|
||||
value="5"
|
||||
subtitle="+2 this month"
|
||||
value={stats.total.toString()}
|
||||
subtitle="Dynamic templates"
|
||||
color="purple"
|
||||
icon={<Activity className="w-5 h-5" />}
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Workflows"
|
||||
value="5"
|
||||
subtitle="All healthy"
|
||||
value={stats.active.toString()}
|
||||
subtitle="Currently running"
|
||||
color="green"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Product Families Assigned"
|
||||
value="34"
|
||||
subtitle="Across 5 workflows"
|
||||
value={stats.totalFamilies.toString()}
|
||||
subtitle="Inheriting configs"
|
||||
color="blue"
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
icon={<Activity className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Pending Approvals"
|
||||
value="89"
|
||||
subtitle="Awaiting action"
|
||||
title="Total Products Assigned"
|
||||
value={stats.totalProducts.toString()}
|
||||
subtitle="In lifecycle workflow"
|
||||
color="orange"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_WORKFLOWS}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search workflows..."
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
|
||||
<DataTable<Workflow>
|
||||
columns={columns}
|
||||
data={workflows}
|
||||
loading={loading}
|
||||
onRowClick={(row) => openPreview(row)}
|
||||
actionConfig={{
|
||||
onView: (row) => openPreview(row),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
customActions: (row) => {
|
||||
const actions = [];
|
||||
if (row.deletedAt) {
|
||||
actions.push({
|
||||
label: "Restore",
|
||||
icon: <RefreshCw className="w-3.5 h-3.5 text-emerald-600" />,
|
||||
onClick: () => setRestoreModal({ isOpen: true, id: row.id, name: row.name })
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
label: "Archive",
|
||||
icon: <Archive className="w-3.5 h-3.5 text-amber-600" />,
|
||||
onClick: () => setArchiveModal({ isOpen: true, id: row.id, name: row.name })
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
label: "Delete Permanently",
|
||||
icon: <Trash2 className="w-3.5 h-3.5 text-red-600" />,
|
||||
onClick: () => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
}}
|
||||
searchPlaceholder="Search workflows..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slide-out Preview Drawer */}
|
||||
{drawerOpen && selectedWorkflow && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-sm transition-opacity">
|
||||
<div className="w-full max-w-xl bg-white h-full shadow-2xl flex flex-col relative animate-slide-in">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/5 border border-primary/10 flex items-center justify-center">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-black text-gray-900 text-sm truncate max-w-[280px]">{selectedWorkflow.name}</h3>
|
||||
{selectedWorkflow.deletedAt && (
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider bg-red-50 text-red-700 px-1.5 py-0.5 rounded">Archived</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 font-mono mt-0.5 block">{selectedWorkflow.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-gray-400 hover:text-gray-600 p-1.5 rounded-lg hover:bg-gray-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{/* Basic Information Section */}
|
||||
<div className="bg-gray-50/50 p-4 rounded-xl border border-gray-150 space-y-4">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest">Workflow Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-400">Category</span>
|
||||
<p className="font-bold text-gray-800 mt-0.5">{selectedWorkflow.category || "Standard Approval"}</p>
|
||||
</div>
|
||||
<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'
|
||||
}`}>
|
||||
{selectedWorkflow.deletedAt ? 'Archived' : selectedWorkflow.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Total Stages</span>
|
||||
<p className="font-bold text-gray-800 mt-0.5">{selectedWorkflow.stages?.length || 0} Stages</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Created Date</span>
|
||||
<p className="font-bold text-gray-800 mt-0.5">
|
||||
{selectedWorkflow.createdAt ? new Date(selectedWorkflow.createdAt).toLocaleDateString() : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-400">Description</span>
|
||||
<p className="font-medium text-gray-600 mt-1 leading-relaxed">{selectedWorkflow.description || "No description provided."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Timeline Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest">Stage Timeline</h4>
|
||||
<span className="text-xs font-bold text-gray-500">{selectedWorkflow.stages?.length || 0} Stages total</span>
|
||||
</div>
|
||||
<div className="border border-gray-150 rounded-xl p-4 bg-white shadow-2xs">
|
||||
<StageTimeline stages={selectedWorkflow.stages || []} variant="vertical" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest">Used By Product Families</h4>
|
||||
<div className="border border-gray-150 rounded-xl overflow-hidden bg-white shadow-2xs">
|
||||
{selectedWorkflow.familiesDetails && selectedWorkflow.familiesDetails.length > 0 ? (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-150 text-gray-400 font-bold uppercase tracking-wider text-[10px]">
|
||||
<th className="p-3">Family Name</th>
|
||||
<th className="p-3">Workflow</th>
|
||||
<th className="p-3">Products</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedWorkflow.familiesDetails.map((f, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50">
|
||||
<td className="p-3 font-semibold text-gray-900">{f.name}</td>
|
||||
<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'
|
||||
}`}>
|
||||
{f.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-400 text-xs flex flex-col items-center justify-center gap-2">
|
||||
<HelpCircle className="w-8 h-8 text-gray-300" />
|
||||
<span>This workflow is not currently assigned to any Product Families.</span>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<Button variant="outline" size="sm" className="text-red-600 bg-white border-red-200 hover:bg-red-50 hover:border-red-300" onClick={() => setDeleteModal({ isOpen: true, id: selectedWorkflow.id, name: selectedWorkflow.name })}>Delete</Button>
|
||||
<Button variant="primary" size="sm" className="bg-primary hover:bg-primary-hover text-white" onClick={() => setDrawerOpen(false)}>Close Preview</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modals */}
|
||||
<ConfirmationModal
|
||||
isOpen={archiveModal.isOpen}
|
||||
title="Archive Workflow"
|
||||
description="Are you sure you want to archive this workflow? Active product families using this workflow will still reference it, but it cannot be assigned to new product families."
|
||||
itemName={archiveModal.name}
|
||||
onConfirm={handleArchiveConfirm}
|
||||
onCancel={() => setArchiveModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={restoreModal.isOpen}
|
||||
title="Restore Workflow"
|
||||
description="Are you sure you want to restore this workflow? It will become visible and assignable again in the system."
|
||||
itemName={restoreModal.name}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
onCancel={() => setRestoreModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Workflow"
|
||||
description="Are you sure you want to delete this workflow? This action cannot be undone."
|
||||
description="Are you sure you want to permanently delete this workflow? This action cannot be undone. If this workflow is assigned to a product family, the deletion will be blocked."
|
||||
itemName={deleteModal.name}
|
||||
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -32,4 +32,14 @@ export const workflowService = {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/workflows/${id}`);
|
||||
return (res as any).success;
|
||||
},
|
||||
|
||||
archive: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/workflows/${id}/archive`);
|
||||
return (res as any).success;
|
||||
},
|
||||
|
||||
restore: async (id: string): Promise<Workflow> => {
|
||||
const res = await apiClient.post<ApiResponse<Workflow>>(`/api/v1/workflows/${id}/restore`);
|
||||
return (res as any).data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
export interface WorkflowStage {
|
||||
name: string;
|
||||
code: string;
|
||||
order: number;
|
||||
color: string;
|
||||
description?: string;
|
||||
actions?: any[]; // future action definitions placeholder
|
||||
}
|
||||
|
||||
export interface FamilyUsageDetails {
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: 'draft' | 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
status: 'draft' | 'active' | 'inactive' | 'archived';
|
||||
stages: WorkflowStage[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
usedBy?: string[];
|
||||
familyCount?: number;
|
||||
productCount?: number;
|
||||
familiesDetails?: FamilyUsageDetails[];
|
||||
}
|
||||
export type WorkflowCreateRequest = Omit<Workflow, 'id' | 'createdAt'>;
|
||||
|
||||
export type WorkflowCreateRequest = Omit<Workflow, 'id' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'usedBy' | 'familyCount' | 'productCount' | 'familiesDetails'>;
|
||||
export type WorkflowUpdateRequest = Partial<WorkflowCreateRequest>;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const initialState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
permissions: { ...defaultPermissions },
|
||||
permissions: {},
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
|
||||
Reference in New Issue
Block a user