merge conflict

This commit is contained in:
MohamedHasan07
2026-07-18 11:50:49 +05:30
47 changed files with 3510 additions and 1579 deletions
+105 -75
View File
@@ -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": {
+2
View File
@@ -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}</>;
};
+23 -1
View File
@@ -1,4 +1,5 @@
// src/components/customs/ActionMenu.tsx
import { type ReactNode } from "react";
import { MoreHorizontal, Eye, Edit2, Trash2 } from "lucide-react";
import {
DropdownMenu,
@@ -7,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(); }}
+16 -3
View File
@@ -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>
)}
+3
View File
@@ -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}
+3
View File
@@ -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>;
+21 -4
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
import { assetTypesService } from '../services/asset-types.service';
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
import { notify } from '../../../services/toast';
@@ -13,7 +14,11 @@ export const useAssetType = () => {
const data = await assetTypesService.getAll();
setItems(data);
} catch (err) {
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);
+119 -191
View File
@@ -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>
);
+15 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { 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>
);
};
+19 -11
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
@@ -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>
);
+267 -81
View File
@@ -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'>;
+15 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import { 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>
);
};
+17 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useFormik } from 'formik';
import { 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>
);
};
+30 -1
View File
@@ -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 };
};
+711 -69
View File
@@ -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>
);
}
+314 -71
View File
@@ -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;
},
};
+30 -3
View File
@@ -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>;
+1 -1
View File
@@ -37,7 +37,7 @@ const initialState: AuthState = {
isAuthenticated: false,
isLoading: false,
user: null,
permissions: { ...defaultPermissions },
permissions: {},
};
const authSlice = createSlice({