feat(digital-signatures): simplify form, add TIFF support, subcategories, download options

- Remove 'initials' type, expirationDate, legalStatus, usageNotes fields
- Add subcategory field with creatable input + 6 default categories
- Add TIFF upload support with client-side utif2 decode for preview
- Store original TIFF data separately for faithful downloads
- Add download dropdown: Original file, Word (.docx), PDF (.pdf)
- Group assets by subcategory in list view
- Add subcategory filter in search bar
- Install docx, jspdf, utif2 packages
- Closes 3.07
This commit is contained in:
AI Assistant
2026-02-28 00:18:29 +02:00
parent a0ec4aed3f
commit 7774a3b622
5 changed files with 1318 additions and 744 deletions
+341 -37
View File
@@ -8,8 +8,11 @@
"name": "architools",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.19.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.0",
"jspdf": "^4.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"minio": "^8.0.6",
@@ -21,10 +24,10 @@
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"utif2": "^4.1.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@prisma/client": "^6.19.2",
"@tailwindcss/postcss": "^4",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
@@ -1994,7 +1997,6 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
"integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@@ -2017,7 +2019,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
"integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -2030,14 +2032,14 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
"integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
"integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2051,14 +2053,14 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
"integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2",
@@ -2070,7 +2072,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
"integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2"
@@ -3605,7 +3607,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@swc/helpers": {
@@ -4025,6 +4027,19 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4054,6 +4069,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -5040,6 +5062,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -5207,7 +5239,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -5236,7 +5268,7 @@
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -5322,6 +5354,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5343,7 +5395,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -5359,7 +5411,7 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -5557,14 +5609,14 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -5620,6 +5672,18 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -5686,6 +5750,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5840,7 +5914,7 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -5928,7 +6002,7 @@
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/depd": {
@@ -5945,7 +6019,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -5987,6 +6061,66 @@
"node": ">=0.10.0"
}
},
"node_modules/docx": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.0.tgz",
"integrity": "sha512-y6EaJJMDvt4P7wgGQB9KsZf4wsRkQMJfkc9LlNufRshggI5BT35hGNkXBCAeEoI3MLMwApKguxzjdqqVcBCqNA==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -6043,7 +6177,7 @@
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -6068,7 +6202,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -6896,14 +7030,14 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -6973,6 +7107,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -7042,6 +7193,12 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -7423,7 +7580,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -7587,6 +7744,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -7634,6 +7801,20 @@
"node": ">=16.9.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -7766,6 +7947,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@@ -8444,7 +8631,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -8554,6 +8741,23 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -9140,6 +9344,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9530,7 +9740,7 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/node-releases": {
@@ -9574,7 +9784,7 @@
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.0",
@@ -9592,7 +9802,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/oauth": {
@@ -9747,7 +9957,7 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/oidc-token-hash": {
@@ -10095,16 +10305,23 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10138,7 +10355,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
@@ -10270,7 +10487,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
@@ -10363,7 +10580,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -10508,6 +10725,16 @@
}
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -10538,7 +10765,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -10669,7 +10896,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -10842,6 +11069,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -11421,6 +11658,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11767,6 +12014,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@@ -11855,6 +12112,16 @@
}
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
@@ -11889,7 +12156,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -12198,7 +12465,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
@@ -12430,6 +12697,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -12449,6 +12725,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
@@ -12724,6 +13010,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+4 -1
View File
@@ -9,11 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/client": "^6.19.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.0",
"jspdf": "^4.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"@prisma/client": "^6.19.2",
"minio": "^8.0.6",
"next": "16.1.6",
"next-auth": "^4.24.13",
@@ -23,6 +25,7 @@
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"utif2": "^4.1.0",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useMemo } from "react";
import {
Plus,
Pencil,
@@ -8,16 +8,17 @@ import {
Search,
PenTool,
Stamp,
Type,
History,
AlertTriangle,
Upload,
X,
Download,
FileText,
FileDown,
ChevronDown,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
@@ -39,22 +40,146 @@ import {
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import type { CompanyId } from "@/core/auth/types";
import type { SignatureAsset, SignatureAssetType } from "../types";
import { DEFAULT_SUBCATEGORIES } from "../types";
import { useSignatures } from "../hooks/use-signatures";
// --------------- constants ---------------
const TYPE_LABELS: Record<SignatureAssetType, string> = {
signature: "Semnătură",
stamp: "Ștampilă",
initials: "Inițiale",
};
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
signature: PenTool,
stamp: Stamp,
initials: Type,
};
// --------------- TIFF -> PNG preview ---------------
async function decodeTiffToPreview(file: File): Promise<string> {
const UTIF = await import("utif2");
const buffer = await file.arrayBuffer();
const ifds = UTIF.decode(buffer);
if (ifds.length === 0) throw new Error("TIFF gol");
const page = ifds[0]!;
UTIF.decodeImage(buffer, page);
const rgba = UTIF.toRGBA8(page);
const canvas = document.createElement("canvas");
canvas.width = page.width;
canvas.height = page.height;
const ctx = canvas.getContext("2d")!;
const imageData = ctx.createImageData(page.width, page.height);
imageData.data.set(rgba);
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
// --------------- download helpers ---------------
function triggerDownload(url: string, filename: string) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function downloadOriginal(asset: SignatureAsset) {
const dataUrl = asset.originalFileData || asset.imageUrl;
if (!dataUrl) return;
const name = asset.originalFileName || `${asset.label}.png`;
triggerDownload(dataUrl, name);
}
async function downloadAsWord(asset: SignatureAsset) {
const { Document, Packer, Paragraph, ImageRun } = await import("docx");
// Use preview (PNG/JPG) for Word since TIFF is not supported
const dataUrl = asset.imageUrl;
if (!dataUrl) return;
const base64 = dataUrl.split(",")[1];
if (!base64) return;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
// Load image to get natural dimensions
const img = new Image();
await new Promise<void>((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = dataUrl;
});
const maxW = 400;
const ratio = img.naturalHeight / (img.naturalWidth || 1);
const w = Math.min(maxW, img.naturalWidth || 200);
const h = Math.round(w * ratio);
const doc = new Document({
sections: [
{
children: [
new Paragraph({
children: [
new ImageRun({
data: bytes,
transformation: { width: w, height: h },
type: dataUrl.includes("image/png") ? "png" : "jpg",
}),
],
}),
],
},
],
});
const blob = await Packer.toBlob(doc);
const objUrl = URL.createObjectURL(blob);
triggerDownload(objUrl, `${asset.label}.docx`);
URL.revokeObjectURL(objUrl);
}
async function downloadAsPdf(asset: SignatureAsset) {
const { jsPDF } = await import("jspdf");
const dataUrl = asset.imageUrl;
if (!dataUrl) return;
// Load image to get natural dimensions
const img = new Image();
await new Promise<void>((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = dataUrl;
});
const doc = new jsPDF();
const pageW = doc.internal.pageSize.getWidth();
const maxW = pageW - 40;
const ratio = img.naturalHeight / (img.naturalWidth || 1);
const w = Math.min(maxW, 120);
const h = w * ratio;
const format = dataUrl.includes("image/png") ? "PNG" : "JPEG";
doc.addImage(dataUrl, format, 20, 20, w, h);
doc.save(`${asset.label}.pdf`);
}
// --------------- main module ---------------
type ViewMode = "list" | "add" | "edit";
export function DigitalSignaturesModule() {
@@ -74,6 +199,32 @@ export function DigitalSignaturesModule() {
const [deletingId, setDeletingId] = useState<string | null>(null);
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
// Collect unique subcategories from all assets + defaults
const allSubcategories = useMemo(() => {
const set = new Set<string>(DEFAULT_SUBCATEGORIES);
for (const a of allAssets) {
if (a.subcategory) set.add(a.subcategory);
}
return Array.from(set).sort();
}, [allAssets]);
// Group filtered assets by subcategory
const groupedAssets = useMemo(() => {
const groups = new Map<string, SignatureAsset[]>();
for (const a of assets) {
const key = a.subcategory || "Fără subcategorie";
const list = groups.get(key) ?? [];
list.push(a);
groups.set(key, list);
}
// Sort groups alphabetically, "Fara subcategorie" last
return Array.from(groups.entries()).sort((a, b) => {
if (a[0] === "Fără subcategorie") return 1;
if (b[0] === "Fără subcategorie") return -1;
return a[0].localeCompare(b[0]);
});
}, [assets]);
const handleSubmit = async (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => {
@@ -100,21 +251,10 @@ export function DigitalSignaturesModule() {
}
};
const isExpiringSoon = (date?: string) => {
if (!date) return false;
const diff = new Date(date).getTime() - Date.now();
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
};
const isExpired = (date?: string) => {
if (!date) return false;
return new Date(date).getTime() < Date.now();
};
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
@@ -137,6 +277,7 @@ export function DigitalSignaturesModule() {
{viewMode === "list" && (
<>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -165,131 +306,62 @@ export function DigitalSignaturesModule() {
))}
</SelectContent>
</Select>
<Select
value={filters.subcategory || "__all__"}
onValueChange={(v) =>
updateFilter("subcategory", v === "__all__" ? "" : v)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Subcategorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Toate subcategoriile</SelectItem>
{allSubcategories.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{/* Asset list grouped by subcategory */}
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : assets.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.
Niciun element găsit. Adaugă o semnătură sau ștampilă.
</p>
) : (
<div className="space-y-6">
{groupedAssets.map(([group, items]) => (
<div key={group}>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
{group}{" "}
<span className="text-xs">({items.length})</span>
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => {
const Icon = TYPE_ICONS[asset.type];
const expired = isExpired(asset.expirationDate);
const expiringSoon = isExpiringSoon(asset.expirationDate);
return (
<Card
{items.map((asset) => (
<AssetCard
key={asset.id}
className={`group relative ${expired ? "border-destructive/50" : expiringSoon ? "border-yellow-500/50" : ""}`}
>
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Versiune nouă"
onClick={() => setVersionAsset(asset)}
>
<History className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
asset={asset}
onEdit={() => {
setEditingAsset(asset);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(asset.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={asset.imageUrl}
alt={asset.label}
className="max-h-10 max-w-10 object-contain"
onDelete={() => setDeletingId(asset.id)}
onVersion={() => setVersionAsset(asset)}
/>
) : (
<Icon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[asset.type]}
</Badge>
<span className="text-xs text-muted-foreground">
{asset.owner}
</span>
))}
</div>
</div>
</div>
{/* Metadata row */}
<div className="mt-2 space-y-1">
{asset.legalStatus && (
<p className="text-xs text-muted-foreground">
Status legal: {asset.legalStatus}
</p>
)}
{asset.expirationDate && (
<div className="flex items-center gap-1 text-xs">
{(expired || expiringSoon) && (
<AlertTriangle className="h-3 w-3 text-yellow-500" />
)}
<span
className={
expired
? "text-destructive font-medium"
: expiringSoon
? "text-yellow-600 font-medium"
: "text-muted-foreground"
}
>
{expired
? "Expirat"
: expiringSoon
? "Expiră curând"
: "Expiră"}
: {asset.expirationDate}
</span>
</div>
)}
{asset.usageNotes && (
<p className="text-xs text-muted-foreground line-clamp-1">
Note: {asset.usageNotes}
</p>
)}
{(asset.versions ?? []).length > 0 && (
<p className="text-xs text-muted-foreground">
Versiuni: {(asset.versions ?? []).length + 1}
</p>
)}
</div>
</CardContent>
</Card>
);
})}
))}
</div>
)}
</>
@@ -305,6 +377,7 @@ export function DigitalSignaturesModule() {
<CardContent>
<AssetForm
initial={editingAsset ?? undefined}
allSubcategories={allSubcategories}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
@@ -363,20 +436,182 @@ export function DigitalSignaturesModule() {
);
}
// --------------- asset card ---------------
function AssetCard({
asset,
onEdit,
onDelete,
onVersion,
}: {
asset: SignatureAsset;
onEdit: () => void;
onDelete: () => void;
onVersion: () => void;
}) {
const Icon = TYPE_ICONS[asset.type];
return (
<Card className="group relative">
<CardContent className="p-4">
{/* Hover actions */}
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Versiune nouă"
onClick={onVersion}
>
<History className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Image + label */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={asset.imageUrl}
alt={asset.label}
className="max-h-10 max-w-10 object-contain"
/>
) : (
<Icon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[asset.type]}
</Badge>
<span className="text-xs text-muted-foreground">
{asset.owner}
</span>
</div>
</div>
</div>
{/* Metadata */}
<div className="mt-2 space-y-1">
{(asset.versions ?? []).length > 0 && (
<p className="text-xs text-muted-foreground">
Versiuni: {(asset.versions ?? []).length + 1}
</p>
)}
</div>
{/* Download buttons */}
{asset.imageUrl && (
<div className="mt-3 border-t pt-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
>
<Download className="mr-1.5 h-3 w-3" />
Descarcă
<ChevronDown className="ml-auto h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => downloadOriginal(asset)}>
<FileDown className="mr-2 h-4 w-4" />
<span>Original</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void downloadAsWord(asset)}
>
<FileText className="mr-2 h-4 w-4" />
<span>Word (.docx)</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void downloadAsPdf(asset)}
>
<FileDown className="mr-2 h-4 w-4" />
<span>PDF (.pdf)</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</CardContent>
</Card>
);
}
// --------------- image upload (TIFF support) ---------------
function ImageUploadField({
value,
onChange,
onOriginalFile,
}: {
value: string;
onChange: (v: string) => void;
onChange: (previewUrl: string) => void;
onOriginalFile?: (dataUrl: string, fileName: string) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [processing, setProcessing] = useState(false);
const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
const handleFile = async (file: File) => {
const isTiff =
file.type === "image/tiff" || /\.tiff?$/i.test(file.name);
if (isTiff) {
setProcessing(true);
try {
const previewUrl = await decodeTiffToPreview(file);
onChange(previewUrl);
// Also store original TIFF
if (onOriginalFile) {
const reader = new FileReader();
reader.onload = (e) => onChange(e.target?.result as string);
reader.onload = (e) =>
onOriginalFile(e.target?.result as string, file.name);
reader.readAsDataURL(file);
}
} catch {
// Fallback: try as regular image
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
onChange(url);
if (onOriginalFile) onOriginalFile(url, file.name);
};
reader.readAsDataURL(file);
} finally {
setProcessing(false);
}
} else if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target?.result as string;
onChange(url);
if (onOriginalFile) onOriginalFile(url, file.name);
};
reader.readAsDataURL(file);
}
};
return (
@@ -388,10 +623,12 @@ function ImageUploadField({
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
if (f) void handleFile(f);
}}
>
{value ? (
{processing ? (
<span className="animate-pulse">Se procesează TIFF...</span>
) : value ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={value}
@@ -401,18 +638,23 @@ function ImageUploadField({
) : (
<>
<Upload className="h-6 w-6" />
<span>Trage imaginea aici sau apasă pentru a selecta</span>
<span>
Trage imaginea aici sau apasă pentru a selecta
</span>
<span className="text-xs text-muted-foreground/60">
PNG, JPG, TIFF
</span>
</>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
accept="image/*,.tif,.tiff"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
if (f) void handleFile(f);
}}
/>
{value && (
@@ -421,7 +663,10 @@ function ImageUploadField({
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => onChange("")}
onClick={() => {
onChange("");
if (onOriginalFile) onOriginalFile("", "");
}}
>
<X className="mr-1 h-3 w-3" /> Elimină imaginea
</Button>
@@ -430,6 +675,8 @@ function ImageUploadField({
);
}
// --------------- add version form ---------------
function AddVersionForm({
onSubmit,
onCancel,
@@ -501,12 +748,16 @@ function AddVersionForm({
);
}
// --------------- asset form ---------------
function AssetForm({
initial,
allSubcategories,
onSubmit,
onCancel,
}: {
initial?: SignatureAsset;
allSubcategories: string[];
onSubmit: (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => void;
@@ -517,15 +768,19 @@ function AssetForm({
initial?.type ?? "signature",
);
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? "");
const [originalFileData, setOriginalFileData] = useState(
initial?.originalFileData ?? "",
);
const [originalFileName, setOriginalFileName] = useState(
initial?.originalFileName ?? "",
);
const [owner, setOwner] = useState(initial?.owner ?? "");
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [expirationDate, setExpirationDate] = useState(
initial?.expirationDate ?? "",
const [subcategory, setSubcategory] = useState(
initial?.subcategory ?? "",
);
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? "");
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? "");
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [tagInput, setTagInput] = useState("");
@@ -552,11 +807,11 @@ function AssetForm({
label,
type,
imageUrl,
originalFileData: originalFileData || undefined,
originalFileName: originalFileName || undefined,
owner,
company,
expirationDate: expirationDate || undefined,
legalStatus,
usageNotes,
subcategory,
versions: initial?.versions ?? [],
tags,
visibility: initial?.visibility ?? "all",
@@ -586,7 +841,6 @@ function AssetForm({
<SelectContent>
<SelectItem value="signature">Semnătură</SelectItem>
<SelectItem value="stamp">Ștampilă</SelectItem>
<SelectItem value="initials">Inițiale</SelectItem>
</SelectContent>
</Select>
</div>
@@ -618,38 +872,32 @@ function AssetForm({
</Select>
</div>
</div>
<div>
<Label>Subcategorie</Label>
<div className="mt-1">
<Input
list="ds-subcategories"
value={subcategory}
onChange={(e) => setSubcategory(e.target.value)}
placeholder="Selectează sau scrie o subcategorie..."
/>
<datalist id="ds-subcategories">
{allSubcategories.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
</div>
<div>
<Label>Imagine</Label>
<div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Data expirare</Label>
<Input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Status legal</Label>
<Input
value={legalStatus}
onChange={(e) => setLegalStatus(e.target.value)}
className="mt-1"
placeholder="Valid, Anulat..."
/>
</div>
<div>
<Label>Note utilizare</Label>
<Input
value={usageNotes}
onChange={(e) => setUsageNotes(e.target.value)}
className="mt-1"
placeholder="Doar pentru contracte..."
<ImageUploadField
value={imageUrl}
onChange={setImageUrl}
onOriginalFile={(data, name) => {
setOriginalFileData(data);
setOriginalFileName(name);
}}
/>
</div>
</div>
@@ -679,7 +927,9 @@ function AssetForm({
if (tagInput.trim()) addTag(tagInput);
}}
placeholder={
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
tags.length === 0
? "Adaugă etichete (Enter sau virgulă)..."
: ""
}
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
@@ -14,6 +14,7 @@ const PREFIX = "sig:";
export interface SignatureFilters {
search: string;
type: SignatureAssetType | "all";
subcategory: string; // "" means all
}
export function useSignatures() {
@@ -23,6 +24,7 @@ export function useSignatures() {
const [filters, setFilters] = useState<SignatureFilters>({
search: "",
type: "all",
subcategory: "",
});
const refresh = useCallback(async () => {
@@ -110,10 +112,14 @@ export function useSignatures() {
const filteredAssets = assets.filter((a) => {
if (filters.type !== "all" && a.type !== filters.type) return false;
if (filters.subcategory && a.subcategory !== filters.subcategory)
return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return (
a.label.toLowerCase().includes(q) || a.owner.toLowerCase().includes(q)
a.label.toLowerCase().includes(q) ||
a.owner.toLowerCase().includes(q) ||
a.subcategory.toLowerCase().includes(q)
);
}
return true;
+20 -9
View File
@@ -1,7 +1,7 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from "@/core/auth/types";
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
export type SignatureAssetType = "signature" | "stamp";
/** Version history entry */
export interface AssetVersion {
@@ -11,19 +11,30 @@ export interface AssetVersion {
createdAt: string;
}
/** Default subcategory options (users can add more) */
export const DEFAULT_SUBCATEGORIES = [
"Colaboratori",
"Experți tehnici",
"Verificatori de proiect",
"Proiectanți",
"Diriginți de șantier",
"Responsabili tehnici",
] as const;
export interface SignatureAsset {
id: string;
label: string;
type: SignatureAssetType;
/** Preview image data URL (PNG/JPG — browsers can render this) */
imageUrl: string;
/** Original file data URL (for TIFF originals or same as imageUrl) */
originalFileData?: string;
/** Original file name for downloads */
originalFileName?: string;
owner: string;
company: CompanyId;
/** Expiration date (YYYY-MM-DD) */
expirationDate?: string;
/** Legal status description */
legalStatus: string;
/** Usage notes */
usageNotes: string;
/** Subcategory for grouping (e.g. "Colaboratori firma X") */
subcategory: string;
/** Version history */
versions: AssetVersion[];
tags: string[];