Prepojenie na externu DB, projekt-zakazky
This commit is contained in:
@@ -17,3 +17,11 @@ FRONTEND_URL="http://localhost:5173"
|
||||
# File Upload
|
||||
UPLOAD_DIR="./uploads"
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# External Database (zakazky) - READ ONLY
|
||||
# Leave empty to disable external zakazky feature
|
||||
EXTERNAL_DB_HOST=""
|
||||
EXTERNAL_DB_PORT=5432
|
||||
EXTERNAL_DB_NAME=""
|
||||
EXTERNAL_DB_USER=""
|
||||
EXTERNAL_DB_PASSWORD=""
|
||||
|
||||
314
backend/package-lock.json
generated
314
backend/package-lock.json
generated
@@ -17,6 +17,8 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^5.22.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.6"
|
||||
@@ -27,7 +29,9 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
@@ -264,6 +268,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
|
||||
@@ -274,6 +288,18 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -383,6 +409,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
@@ -495,9 +527,19 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -543,6 +585,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -1285,7 +1342,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -1353,6 +1409,79 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -1459,6 +1588,95 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
"pg-protocol": "^1.11.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1472,6 +1690,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
@@ -1543,6 +1800,20 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -1776,6 +2047,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1785,6 +2065,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -1994,6 +2291,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2024,6 +2327,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
@@ -2063,7 +2372,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^5.22.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.6"
|
||||
@@ -44,7 +46,9 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
||||
@@ -64,6 +64,7 @@ model User {
|
||||
rmaStatusChanges RMAStatusHistory[]
|
||||
rmaComments RMAComment[]
|
||||
taskComments Comment[]
|
||||
taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader")
|
||||
|
||||
createdCustomers Customer[]
|
||||
|
||||
@@ -377,6 +378,7 @@ model Task {
|
||||
reminders Reminder[]
|
||||
comments Comment[]
|
||||
tags TaskTag[]
|
||||
attachments TaskAttachment[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([parentId])
|
||||
@@ -410,6 +412,24 @@ model TaskTag {
|
||||
@@id([taskId, tagId])
|
||||
}
|
||||
|
||||
model TaskAttachment {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
filename String
|
||||
filepath String
|
||||
mimetype String
|
||||
size Int
|
||||
|
||||
uploadedById String
|
||||
uploadedBy User @relation("TaskAttachmentUploader", fields: [uploadedById], references: [id])
|
||||
|
||||
uploadedAt DateTime @default(now())
|
||||
|
||||
@@index([taskId])
|
||||
}
|
||||
|
||||
model Reminder {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
|
||||
@@ -23,6 +23,13 @@ export const env = {
|
||||
UPLOAD_DIR: process.env.UPLOAD_DIR || './uploads',
|
||||
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10),
|
||||
|
||||
// External Database (zakazky)
|
||||
EXTERNAL_DB_HOST: process.env.EXTERNAL_DB_HOST || '',
|
||||
EXTERNAL_DB_PORT: parseInt(process.env.EXTERNAL_DB_PORT || '5432', 10),
|
||||
EXTERNAL_DB_NAME: process.env.EXTERNAL_DB_NAME || '',
|
||||
EXTERNAL_DB_USER: process.env.EXTERNAL_DB_USER || '',
|
||||
EXTERNAL_DB_PASSWORD: process.env.EXTERNAL_DB_PASSWORD || '',
|
||||
|
||||
// Helpers
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
isProd: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Response } from 'express';
|
||||
import prisma from '../config/database';
|
||||
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { movePendingFilesToEntity } from './upload.controller';
|
||||
|
||||
export const getEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -112,6 +113,11 @@ export const createEquipment = async (req: AuthRequest, res: Response): Promise<
|
||||
},
|
||||
});
|
||||
|
||||
// Move pending files if tempId provided
|
||||
if (req.body.tempId) {
|
||||
await movePendingFilesToEntity(req.body.tempId, 'equipment', equipment.id);
|
||||
}
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('CREATE', 'Equipment', equipment.id, { name: equipment.name });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../config/database';
|
||||
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { configService } from '../services/config.service';
|
||||
import { movePendingFilesToEntity } from './upload.controller';
|
||||
|
||||
async function generateRMANumber(): Promise<string> {
|
||||
const today = new Date();
|
||||
@@ -192,6 +193,11 @@ export const createRMA = async (req: AuthRequest, res: Response): Promise<void>
|
||||
},
|
||||
});
|
||||
|
||||
// Move pending files if tempId provided
|
||||
if (req.body.tempId) {
|
||||
await movePendingFilesToEntity(req.body.tempId, 'rma', rma.id);
|
||||
}
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('CREATE', 'RMA', rma.id, { rmaNumber });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../config/database';
|
||||
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { configService } from '../services/config.service';
|
||||
import { movePendingFilesToEntity } from './upload.controller';
|
||||
|
||||
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -168,6 +169,11 @@ export const createTask = async (req: AuthRequest, res: Response): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
// Move pending files if tempId provided
|
||||
if (req.body.tempId) {
|
||||
await movePendingFilesToEntity(req.body.tempId, 'task', task.id);
|
||||
}
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('CREATE', 'Task', task.id, { title: task.title });
|
||||
}
|
||||
|
||||
586
backend/src/controllers/upload.controller.ts
Normal file
586
backend/src/controllers/upload.controller.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import prisma from '../config/database';
|
||||
import { successResponse, errorResponse, getParam } from '../utils/helpers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { getFileUrl, getFilePath } from '../middleware/upload.middleware';
|
||||
import { env } from '../config/env';
|
||||
|
||||
// Pending file info stored in memory (for simplicity - in production use Redis)
|
||||
interface PendingFile {
|
||||
tempFilename: string;
|
||||
originalFilename: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
uploadedById: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
const pendingFiles = new Map<string, PendingFile[]>();
|
||||
|
||||
// Helper to get pending directory
|
||||
const getPendingDir = () => path.join(env.UPLOAD_DIR || 'uploads', 'pending');
|
||||
|
||||
// Pending uploads - for new entities that don't have ID yet
|
||||
export const uploadPendingFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const tempId = getParam(req, 'tempId');
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing pending files for this tempId
|
||||
const existing = pendingFiles.get(tempId) || [];
|
||||
|
||||
// Add new files to pending
|
||||
const newPendingFiles: PendingFile[] = files.map((file) => ({
|
||||
tempFilename: file.filename,
|
||||
originalFilename: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: req.user!.userId,
|
||||
uploadedAt: new Date(),
|
||||
}));
|
||||
|
||||
pendingFiles.set(tempId, [...existing, ...newPendingFiles]);
|
||||
|
||||
// Return file info (without DB id since they're not saved yet)
|
||||
const response = newPendingFiles.map((f, index) => ({
|
||||
id: `pending-${tempId}-${Date.now()}-${index}`,
|
||||
filename: f.originalFilename,
|
||||
filepath: `/uploads/pending/${f.tempFilename}`,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
uploadedAt: f.uploadedAt.toISOString(),
|
||||
isPending: true,
|
||||
}));
|
||||
|
||||
successResponse(res, response, 'Súbory boli nahrané.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error uploading pending files:', error);
|
||||
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const getPendingFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const tempId = getParam(req, 'tempId');
|
||||
const files = pendingFiles.get(tempId) || [];
|
||||
|
||||
const response = files.map((f, index) => ({
|
||||
id: `pending-${tempId}-${index}`,
|
||||
filename: f.originalFilename,
|
||||
filepath: `/uploads/pending/${f.tempFilename}`,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
uploadedAt: f.uploadedAt.toISOString(),
|
||||
isPending: true,
|
||||
}));
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
console.error('Error getting pending files:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePendingFile = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const tempId = getParam(req, 'tempId');
|
||||
const filename = getParam(req, 'filename');
|
||||
|
||||
const files = pendingFiles.get(tempId) || [];
|
||||
const fileIndex = files.findIndex((f) => f.tempFilename === filename || f.originalFilename === filename);
|
||||
|
||||
if (fileIndex === -1) {
|
||||
errorResponse(res, 'Súbor nebol nájdený.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[fileIndex];
|
||||
|
||||
// Delete physical file
|
||||
const filePath = path.join(getPendingDir(), file.tempFilename);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
console.warn(`Pending file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// Remove from pending list
|
||||
files.splice(fileIndex, 1);
|
||||
if (files.length === 0) {
|
||||
pendingFiles.delete(tempId);
|
||||
} else {
|
||||
pendingFiles.set(tempId, files);
|
||||
}
|
||||
|
||||
successResponse(res, null, 'Súbor bol vymazaný.');
|
||||
} catch (error) {
|
||||
console.error('Error deleting pending file:', error);
|
||||
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to move pending files to entity
|
||||
export const movePendingFilesToEntity = async (
|
||||
tempId: string,
|
||||
entityType: 'equipment' | 'rma' | 'task',
|
||||
entityId: string
|
||||
): Promise<void> => {
|
||||
const files = pendingFiles.get(tempId);
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const targetDir = path.join(env.UPLOAD_DIR || 'uploads', entityType);
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(getPendingDir(), file.tempFilename);
|
||||
const targetPath = path.join(targetDir, file.tempFilename);
|
||||
|
||||
// Move file
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.renameSync(sourcePath, targetPath);
|
||||
|
||||
// Create DB record
|
||||
if (entityType === 'equipment') {
|
||||
await prisma.equipmentAttachment.create({
|
||||
data: {
|
||||
equipmentId: entityId,
|
||||
filename: file.originalFilename,
|
||||
filepath: getFileUrl('equipment', file.tempFilename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: file.uploadedById,
|
||||
},
|
||||
});
|
||||
} else if (entityType === 'rma') {
|
||||
await prisma.rMAAttachment.create({
|
||||
data: {
|
||||
rmaId: entityId,
|
||||
filename: file.originalFilename,
|
||||
filepath: getFileUrl('rma', file.tempFilename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: file.uploadedById,
|
||||
},
|
||||
});
|
||||
} else if (entityType === 'task') {
|
||||
await prisma.taskAttachment.create({
|
||||
data: {
|
||||
taskId: entityId,
|
||||
filename: file.originalFilename,
|
||||
filepath: getFileUrl('task', file.tempFilename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: file.uploadedById,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear pending files
|
||||
pendingFiles.delete(tempId);
|
||||
};
|
||||
|
||||
// Equipment Attachments
|
||||
export const uploadEquipmentFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const equipmentId = getParam(req, 'id');
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skontrolovať, či equipment existuje
|
||||
const equipment = await prisma.equipment.findUnique({
|
||||
where: { id: equipmentId },
|
||||
});
|
||||
|
||||
if (!equipment) {
|
||||
// Vymazať nahrané súbory
|
||||
files.forEach((file) => {
|
||||
fs.unlink(file.path, () => {});
|
||||
});
|
||||
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uložiť záznamy do databázy
|
||||
const attachments = await Promise.all(
|
||||
files.map((file) =>
|
||||
prisma.equipmentAttachment.create({
|
||||
data: {
|
||||
equipmentId,
|
||||
filename: file.originalname,
|
||||
filepath: getFileUrl('equipment', file.filename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: req.user!.userId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('UPLOAD', 'Equipment', equipmentId, {
|
||||
files: attachments.map((a) => a.filename),
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error uploading equipment files:', error);
|
||||
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const getEquipmentFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const equipmentId = getParam(req, 'id');
|
||||
|
||||
const attachments = await prisma.equipmentAttachment.findMany({
|
||||
where: { equipmentId },
|
||||
orderBy: { uploadedAt: 'desc' },
|
||||
include: {
|
||||
uploadedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
successResponse(res, attachments);
|
||||
} catch (error) {
|
||||
console.error('Error fetching equipment files:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEquipmentFile = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const equipmentId = getParam(req, 'id');
|
||||
const fileId = getParam(req, 'fileId');
|
||||
|
||||
const attachment = await prisma.equipmentAttachment.findFirst({
|
||||
where: { id: fileId, equipmentId },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
errorResponse(res, 'Súbor nebol nájdený.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vymazať fyzický súbor
|
||||
const filename = path.basename(attachment.filepath);
|
||||
const filePath = getFilePath('equipment', filename);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Súbor možno už neexistuje
|
||||
console.warn(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// Vymazať záznam z databázy
|
||||
await prisma.equipmentAttachment.delete({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('DELETE_FILE', 'Equipment', equipmentId, {
|
||||
filename: attachment.filename,
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, null, 'Súbor bol vymazaný.');
|
||||
} catch (error) {
|
||||
console.error('Error deleting equipment file:', error);
|
||||
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// RMA Attachments
|
||||
export const uploadRMAFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const rmaId = getParam(req, 'id');
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skontrolovať, či RMA existuje
|
||||
const rma = await prisma.rMA.findUnique({
|
||||
where: { id: rmaId },
|
||||
});
|
||||
|
||||
if (!rma) {
|
||||
// Vymazať nahrané súbory
|
||||
files.forEach((file) => {
|
||||
fs.unlink(file.path, () => {});
|
||||
});
|
||||
errorResponse(res, 'RMA nebola nájdená.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uložiť záznamy do databázy
|
||||
const attachments = await Promise.all(
|
||||
files.map((file) =>
|
||||
prisma.rMAAttachment.create({
|
||||
data: {
|
||||
rmaId,
|
||||
filename: file.originalname,
|
||||
filepath: getFileUrl('rma', file.filename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: req.user!.userId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('UPLOAD', 'RMA', rmaId, {
|
||||
files: attachments.map((a) => a.filename),
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error uploading RMA files:', error);
|
||||
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const getRMAFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const rmaId = getParam(req, 'id');
|
||||
|
||||
const attachments = await prisma.rMAAttachment.findMany({
|
||||
where: { rmaId },
|
||||
orderBy: { uploadedAt: 'desc' },
|
||||
include: {
|
||||
uploadedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
successResponse(res, attachments);
|
||||
} catch (error) {
|
||||
console.error('Error fetching RMA files:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRMAFile = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const rmaId = getParam(req, 'id');
|
||||
const fileId = getParam(req, 'fileId');
|
||||
|
||||
const attachment = await prisma.rMAAttachment.findFirst({
|
||||
where: { id: fileId, rmaId },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
errorResponse(res, 'Súbor nebol nájdený.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vymazať fyzický súbor
|
||||
const filename = path.basename(attachment.filepath);
|
||||
const filePath = getFilePath('rma', filename);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Súbor možno už neexistuje
|
||||
console.warn(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// Vymazať záznam z databázy
|
||||
await prisma.rMAAttachment.delete({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('DELETE_FILE', 'RMA', rmaId, {
|
||||
filename: attachment.filename,
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, null, 'Súbor bol vymazaný.');
|
||||
} catch (error) {
|
||||
console.error('Error deleting RMA file:', error);
|
||||
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Task Attachments
|
||||
export const uploadTaskFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const taskId = getParam(req, 'id');
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
errorResponse(res, 'Žiadne súbory neboli nahrané.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skontrolovať, či task existuje
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
// Vymazať nahrané súbory
|
||||
files.forEach((file) => {
|
||||
fs.unlink(file.path, () => {});
|
||||
});
|
||||
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uložiť záznamy do databázy
|
||||
const attachments = await Promise.all(
|
||||
files.map((file) =>
|
||||
prisma.taskAttachment.create({
|
||||
data: {
|
||||
taskId,
|
||||
filename: file.originalname,
|
||||
filepath: getFileUrl('task', file.filename),
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedById: req.user!.userId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('UPLOAD', 'Task', taskId, {
|
||||
files: attachments.map((a) => a.filename),
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, attachments, 'Súbory boli nahrané.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error uploading task files:', error);
|
||||
errorResponse(res, 'Chyba pri nahrávaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTaskFiles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const taskId = getParam(req, 'id');
|
||||
|
||||
const attachments = await prisma.taskAttachment.findMany({
|
||||
where: { taskId },
|
||||
orderBy: { uploadedAt: 'desc' },
|
||||
include: {
|
||||
uploadedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
successResponse(res, attachments);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task files:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní súborov.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTaskFile = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const taskId = getParam(req, 'id');
|
||||
const fileId = getParam(req, 'fileId');
|
||||
|
||||
const attachment = await prisma.taskAttachment.findFirst({
|
||||
where: { id: fileId, taskId },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
errorResponse(res, 'Súbor nebol nájdený.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vymazať fyzický súbor
|
||||
const filename = path.basename(attachment.filepath);
|
||||
const filePath = getFilePath('task', filename);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Súbor možno už neexistuje
|
||||
console.warn(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// Vymazať záznam z databázy
|
||||
await prisma.taskAttachment.delete({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('DELETE_FILE', 'Task', taskId, {
|
||||
filename: attachment.filename,
|
||||
});
|
||||
}
|
||||
|
||||
successResponse(res, null, 'Súbor bol vymazaný.');
|
||||
} catch (error) {
|
||||
console.error('Error deleting task file:', error);
|
||||
errorResponse(res, 'Chyba pri mazaní súboru.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Download file (universal)
|
||||
export const downloadFile = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const fileId = getParam(req, 'fileId');
|
||||
const entityType = getParam(req, 'entityType'); // 'equipment', 'rma', or 'task'
|
||||
|
||||
let attachment: { filepath: string; filename: string } | null = null;
|
||||
|
||||
if (entityType === 'equipment') {
|
||||
attachment = await prisma.equipmentAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { filepath: true, filename: true },
|
||||
});
|
||||
} else if (entityType === 'rma') {
|
||||
attachment = await prisma.rMAAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { filepath: true, filename: true },
|
||||
});
|
||||
} else if (entityType === 'task') {
|
||||
attachment = await prisma.taskAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { filepath: true, filename: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!attachment) {
|
||||
errorResponse(res, 'Súbor nebol nájdený.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = path.basename(attachment.filepath);
|
||||
const filePath = getFilePath(entityType, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
errorResponse(res, 'Súbor nebol nájdený na serveri.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
res.download(filePath, attachment.filename);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
errorResponse(res, 'Chyba pri sťahovaní súboru.', 500);
|
||||
}
|
||||
};
|
||||
48
backend/src/controllers/zakazky.controller.ts
Normal file
48
backend/src/controllers/zakazky.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Response } from 'express';
|
||||
import { successResponse, errorResponse, parseQueryInt, getQueryString } from '../utils/helpers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { externalDbService } from '../services/externalDb.service';
|
||||
|
||||
// Check if external DB is configured
|
||||
export const checkConfiguration = async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const isConfigured = externalDbService.isConfigured();
|
||||
successResponse(res, { configured: isConfigured });
|
||||
};
|
||||
|
||||
// Get zakazky by year
|
||||
export const getZakazky = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!externalDbService.isConfigured()) {
|
||||
errorResponse(res, 'Externá databáza nie je nakonfigurovaná.', 503);
|
||||
return;
|
||||
}
|
||||
|
||||
const rok = parseQueryInt(req.query.rok, new Date().getFullYear());
|
||||
const search = getQueryString(req, 'search');
|
||||
|
||||
let zakazky;
|
||||
|
||||
if (search) {
|
||||
zakazky = await externalDbService.searchZakazky(rok, search);
|
||||
} else {
|
||||
zakazky = await externalDbService.getZakazkyByYear(rok);
|
||||
}
|
||||
|
||||
successResponse(res, zakazky);
|
||||
} catch (error) {
|
||||
console.error('Error fetching zakazky:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní zákaziek z externej databázy.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Get available years (current year and 5 years back)
|
||||
export const getAvailableYears = async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
years.push(currentYear - i);
|
||||
}
|
||||
|
||||
successResponse(res, years);
|
||||
};
|
||||
@@ -2,15 +2,40 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { env } from './config/env';
|
||||
import routes from './routes';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
import prisma from './config/database';
|
||||
|
||||
// Ensure upload directories exist
|
||||
const ensureUploadDirectories = () => {
|
||||
const uploadDir = env.UPLOAD_DIR || 'uploads';
|
||||
const directories = [
|
||||
uploadDir,
|
||||
path.join(uploadDir, 'equipment'),
|
||||
path.join(uploadDir, 'rma'),
|
||||
path.join(uploadDir, 'task'),
|
||||
path.join(uploadDir, 'pending'),
|
||||
];
|
||||
|
||||
directories.forEach((dir) => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`Created directory: ${dir}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ensureUploadDirectories();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Pre static files z iného originu
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
@@ -20,6 +45,10 @@ app.use(cors({
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Static files - uploads
|
||||
const uploadsPath = path.resolve(env.UPLOAD_DIR);
|
||||
app.use('/uploads', express.static(uploadsPath));
|
||||
|
||||
// Logging
|
||||
if (env.isDev) {
|
||||
app.use(morgan('dev'));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AuthRequest } from './auth.middleware';
|
||||
import prisma from '../config/database';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
type ActionType = 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' | 'LOGIN' | 'LOGOUT';
|
||||
type ActionType = 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' | 'LOGIN' | 'LOGOUT' | 'UPLOAD' | 'DELETE_FILE';
|
||||
type EntityType = 'User' | 'Project' | 'Task' | 'Customer' | 'Equipment' | 'Revision' | 'RMA';
|
||||
|
||||
export const logActivity = async (
|
||||
|
||||
164
backend/src/middleware/upload.middleware.ts
Normal file
164
backend/src/middleware/upload.middleware.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { Request } from 'express';
|
||||
import { env } from '../config/env';
|
||||
|
||||
// Povolené MIME typy
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
// Obrázky
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// Dokumenty
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
];
|
||||
|
||||
// Povolené prípony
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp',
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
||||
'.txt', '.csv',
|
||||
];
|
||||
|
||||
// Maximálna veľkosť súboru (default 10MB)
|
||||
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE || '10485760', 10);
|
||||
|
||||
// Storage konfigurácia
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req: Request, _file, cb) => {
|
||||
// Určenie cieľového priečinka podľa entity type
|
||||
const entityType = req.params.entityType || req.body.entityType || 'general';
|
||||
const uploadPath = path.join(env.UPLOAD_DIR || 'uploads', entityType);
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
// Generovanie unikátneho názvu: timestamp-random.extension
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
// File filter
|
||||
const fileFilter = (
|
||||
_req: Request,
|
||||
file: Express.Multer.File,
|
||||
cb: multer.FileFilterCallback
|
||||
) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
// Kontrola MIME typu
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
cb(new Error(`Nepodporovaný typ súboru: ${file.mimetype}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Kontrola prípony
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(new Error(`Nepodporovaná prípona súboru: ${ext}`));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Základná multer inštancia
|
||||
export const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 10, // Max počet súborov naraz
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-configured uploaders pre rôzne entity
|
||||
export const equipmentUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'equipment'));
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export const rmaUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'rma'));
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Pending upload - for files before entity is created
|
||||
export const pendingUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'pending'));
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export const taskUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, path.join(env.UPLOAD_DIR || 'uploads', 'task'));
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Helper pre získanie URL súboru
|
||||
export const getFileUrl = (entityType: string, filename: string): string => {
|
||||
return `/uploads/${entityType}/${filename}`;
|
||||
};
|
||||
|
||||
// Helper pre získanie cesty súboru
|
||||
export const getFilePath = (entityType: string, filename: string): string => {
|
||||
return path.join(env.UPLOAD_DIR || 'uploads', entityType, filename);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import * as equipmentController from '../controllers/equipment.controller';
|
||||
import * as uploadController from '../controllers/upload.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||
import { validate } from '../middleware/validate.middleware';
|
||||
import { equipmentSchema } from '../utils/validators';
|
||||
import { equipmentUpload } from '../middleware/upload.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -22,4 +24,9 @@ router.delete('/:id', canDelete('equipment'), equipmentController.deleteEquipmen
|
||||
router.get('/:id/revisions', canRead('equipment'), equipmentController.getEquipmentRevisions);
|
||||
router.post('/:id/revisions', canCreate('equipment'), equipmentController.createEquipmentRevision);
|
||||
|
||||
// File attachments
|
||||
router.get('/:id/files', canRead('equipment'), uploadController.getEquipmentFiles);
|
||||
router.post('/:id/files', canCreate('equipment'), equipmentUpload.array('files', 10), uploadController.uploadEquipmentFiles);
|
||||
router.delete('/:id/files/:fileId', canDelete('equipment'), uploadController.deleteEquipmentFile);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,6 +8,8 @@ import equipmentRoutes from './equipment.routes';
|
||||
import rmaRoutes from './rma.routes';
|
||||
import settingsRoutes from './settings.routes';
|
||||
import dashboardRoutes from './dashboard.routes';
|
||||
import uploadRoutes from './upload.routes';
|
||||
import zakazkyRoutes from './zakazky.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -20,5 +22,7 @@ router.use('/equipment', equipmentRoutes);
|
||||
router.use('/rma', rmaRoutes);
|
||||
router.use('/settings', settingsRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/files', uploadRoutes);
|
||||
router.use('/zakazky', zakazkyRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import * as rmaController from '../controllers/rma.controller';
|
||||
import * as uploadController from '../controllers/upload.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { canRead, canCreate, canUpdate, canDelete, isAdmin } from '../middleware/rbac.middleware';
|
||||
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||
import { validate } from '../middleware/validate.middleware';
|
||||
import { rmaSchema } from '../utils/validators';
|
||||
import { rmaUpload } from '../middleware/upload.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -27,4 +29,9 @@ router.patch('/:id/approve', isAdmin, rmaController.approveRMA);
|
||||
// Comments
|
||||
router.post('/:id/comments', canRead('rma'), rmaController.addRMAComment);
|
||||
|
||||
// File attachments
|
||||
router.get('/:id/files', canRead('rma'), uploadController.getRMAFiles);
|
||||
router.post('/:id/files', canCreate('rma'), rmaUpload.array('files', 10), uploadController.uploadRMAFiles);
|
||||
router.delete('/:id/files/:fileId', canDelete('rma'), uploadController.deleteRMAFile);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import * as tasksController from '../controllers/tasks.controller';
|
||||
import * as uploadController from '../controllers/upload.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||
import { validate } from '../middleware/validate.middleware';
|
||||
import { taskSchema } from '../utils/validators';
|
||||
import { taskUpload } from '../middleware/upload.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -28,4 +30,9 @@ router.delete('/:id/assignees/:userId', canUpdate('tasks'), tasksController.remo
|
||||
router.get('/:id/comments', canRead('tasks'), tasksController.getTaskComments);
|
||||
router.post('/:id/comments', canRead('tasks'), tasksController.addTaskComment);
|
||||
|
||||
// Files
|
||||
router.post('/:id/files', canUpdate('tasks'), taskUpload.array('files', 10), uploadController.uploadTaskFiles);
|
||||
router.get('/:id/files', canRead('tasks'), uploadController.getTaskFiles);
|
||||
router.delete('/:id/files/:fileId', canUpdate('tasks'), uploadController.deleteTaskFile);
|
||||
|
||||
export default router;
|
||||
|
||||
20
backend/src/routes/upload.routes.ts
Normal file
20
backend/src/routes/upload.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import * as uploadController from '../controllers/upload.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||
import { pendingUpload } from '../middleware/upload.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(activityLogger);
|
||||
|
||||
// Pending uploads - for new entities before they are saved
|
||||
router.post('/pending/:tempId', pendingUpload.array('files', 10), uploadController.uploadPendingFiles);
|
||||
router.get('/pending/:tempId', uploadController.getPendingFiles);
|
||||
router.delete('/pending/:tempId/:filename', uploadController.deletePendingFile);
|
||||
|
||||
// Download file by entity type and file ID
|
||||
router.get('/:entityType/:fileId/download', uploadController.downloadFile);
|
||||
|
||||
export default router;
|
||||
21
backend/src/routes/zakazky.routes.ts
Normal file
21
backend/src/routes/zakazky.routes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import * as zakazkyController from '../controllers/zakazky.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { canRead } from '../middleware/rbac.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// Check if external DB is configured
|
||||
router.get('/status', zakazkyController.checkConfiguration);
|
||||
|
||||
// Get available years
|
||||
router.get('/years', zakazkyController.getAvailableYears);
|
||||
|
||||
// Get zakazky by year (with optional search)
|
||||
// Query params: rok (year), search (optional)
|
||||
router.get('/', canRead('projects'), zakazkyController.getZakazky);
|
||||
|
||||
export default router;
|
||||
173
backend/src/services/externalDb.service.ts
Normal file
173
backend/src/services/externalDb.service.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { env } from '../config/env';
|
||||
|
||||
// External database connection pool - READ ONLY
|
||||
let pool: Pool | null = null;
|
||||
|
||||
// Forbidden SQL keywords for safety
|
||||
const FORBIDDEN_KEYWORDS = [
|
||||
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE',
|
||||
'GRANT', 'REVOKE', 'EXECUTE', 'CALL', 'MERGE', 'REPLACE'
|
||||
];
|
||||
|
||||
// Validate query is read-only
|
||||
const validateReadOnlyQuery = (query: string): void => {
|
||||
const upperQuery = query.toUpperCase().trim();
|
||||
|
||||
for (const keyword of FORBIDDEN_KEYWORDS) {
|
||||
// Check if query starts with forbidden keyword or contains it as a statement
|
||||
if (upperQuery.startsWith(keyword) || upperQuery.includes(`;${keyword}`) || upperQuery.includes(`; ${keyword}`)) {
|
||||
throw new Error(`Forbidden operation: ${keyword} is not allowed on external database. Read-only access only.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Must start with SELECT or be a function call that returns data
|
||||
if (!upperQuery.startsWith('SELECT')) {
|
||||
throw new Error('Only SELECT queries are allowed on external database.');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize connection pool
|
||||
const getPool = (): Pool => {
|
||||
if (!pool) {
|
||||
if (!env.EXTERNAL_DB_HOST || !env.EXTERNAL_DB_NAME) {
|
||||
throw new Error('External database not configured. Check EXTERNAL_DB_* environment variables.');
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
host: env.EXTERNAL_DB_HOST,
|
||||
port: env.EXTERNAL_DB_PORT,
|
||||
database: env.EXTERNAL_DB_NAME,
|
||||
user: env.EXTERNAL_DB_USER,
|
||||
password: env.EXTERNAL_DB_PASSWORD,
|
||||
max: 5, // Max connections in pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
// Handle pool errors
|
||||
pool.on('error', (err) => {
|
||||
console.error('External DB pool error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
};
|
||||
|
||||
// Safe read-only query executor
|
||||
const executeReadOnlyQuery = async <T>(query: string, params?: unknown[]): Promise<T[]> => {
|
||||
// Validate query before execution
|
||||
validateReadOnlyQuery(query);
|
||||
|
||||
const dbPool = getPool();
|
||||
let client: PoolClient | null = null;
|
||||
|
||||
try {
|
||||
client = await dbPool.connect();
|
||||
|
||||
// Set transaction to read-only for extra safety
|
||||
await client.query('SET TRANSACTION READ ONLY');
|
||||
|
||||
const result = await client.query(query, params);
|
||||
return result.rows as T[];
|
||||
} finally {
|
||||
if (client) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if external DB is configured
|
||||
export const isExternalDbConfigured = (): boolean => {
|
||||
return !!(env.EXTERNAL_DB_HOST && env.EXTERNAL_DB_NAME);
|
||||
};
|
||||
|
||||
// Zakazka interface based on function output
|
||||
export interface Zakazka {
|
||||
id: number;
|
||||
id_stav_zakazky: number;
|
||||
cislo: string;
|
||||
datum_vystavenia: Date | null;
|
||||
datum_ukoncenia: Date | null;
|
||||
customer: string;
|
||||
nazov: string;
|
||||
poznamka: string | null;
|
||||
vystavil: string;
|
||||
uzavreta: boolean;
|
||||
}
|
||||
|
||||
// Raw row interface from DB function (id without underscore, rest with underscore)
|
||||
interface ZakazkaRow {
|
||||
id: number;
|
||||
_id_stav_zakazky: number;
|
||||
_cislo: string;
|
||||
_datum_vystavenia: Date | null;
|
||||
_datum_ukoncenia: Date | null;
|
||||
_customer: string;
|
||||
_nazov: string;
|
||||
_poznamka: string | null;
|
||||
_vystavil: string;
|
||||
_uzavreta: boolean;
|
||||
}
|
||||
|
||||
// Get zakazky by year - READ ONLY
|
||||
export const getZakazkyByYear = async (rok: number): Promise<Zakazka[]> => {
|
||||
try {
|
||||
// Call the stored function using safe read-only executor
|
||||
const rows = await executeReadOnlyQuery<ZakazkaRow>(
|
||||
'SELECT * FROM da.zakazky_select_all($1)',
|
||||
[rok]
|
||||
);
|
||||
|
||||
// Map the result to our interface
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
id_stav_zakazky: row._id_stav_zakazky,
|
||||
cislo: row._cislo,
|
||||
datum_vystavenia: row._datum_vystavenia,
|
||||
datum_ukoncenia: row._datum_ukoncenia,
|
||||
customer: row._customer,
|
||||
nazov: row._nazov,
|
||||
poznamka: row._poznamka,
|
||||
vystavil: row._vystavil,
|
||||
uzavreta: row._uzavreta,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching zakazky:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get single zakazka by ID
|
||||
export const getZakazkaById = async (rok: number, id: number): Promise<Zakazka | null> => {
|
||||
const zakazky = await getZakazkyByYear(rok);
|
||||
return zakazky.find((z) => z.id === id) || null;
|
||||
};
|
||||
|
||||
// Search zakazky
|
||||
export const searchZakazky = async (rok: number, search: string): Promise<Zakazka[]> => {
|
||||
const zakazky = await getZakazkyByYear(rok);
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
return zakazky.filter((z) =>
|
||||
z.cislo.toLowerCase().includes(searchLower) ||
|
||||
z.nazov.toLowerCase().includes(searchLower) ||
|
||||
z.customer.toLowerCase().includes(searchLower)
|
||||
);
|
||||
};
|
||||
|
||||
// Close pool (for graceful shutdown)
|
||||
export const closeExternalDb = async (): Promise<void> => {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const externalDbService = {
|
||||
isConfigured: isExternalDbConfigured,
|
||||
getZakazkyByYear,
|
||||
getZakazkaById,
|
||||
searchZakazky,
|
||||
close: closeExternalDb,
|
||||
};
|
||||
@@ -11,8 +11,8 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projekty' },
|
||||
{ to: '/tasks', icon: CheckSquare, label: 'Úlohy' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Zákazky' },
|
||||
{ to: '/customers', icon: Users, label: 'Zákazníci' },
|
||||
{ to: '/equipment', icon: Wrench, label: 'Zariadenia' },
|
||||
{ to: '/rma', icon: RotateCcw, label: 'RMA' },
|
||||
|
||||
237
frontend/src/components/ui/FileUpload.tsx
Normal file
237
frontend/src/components/ui/FileUpload.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, File, Image, FileText, Loader2, Download, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Attachment } from '@/types';
|
||||
import {
|
||||
uploadFiles,
|
||||
deleteFile,
|
||||
getFilePreviewUrl,
|
||||
getDownloadUrl,
|
||||
formatFileSize,
|
||||
isImageFile,
|
||||
type EntityType,
|
||||
} from '@/services/upload.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface FileUploadProps {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
files: Attachment[];
|
||||
onFilesChange: (files: Attachment[]) => void;
|
||||
maxFiles?: number;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
entityType,
|
||||
entityId,
|
||||
files,
|
||||
onFilesChange,
|
||||
maxFiles = 10,
|
||||
accept = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv',
|
||||
disabled = false,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
await handleUpload(droppedFiles);
|
||||
},
|
||||
[disabled, entityType, entityId, files, maxFiles]
|
||||
);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
await handleUpload(selectedFiles);
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (newFiles: File[]) => {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
// Check max files
|
||||
if (files.length + newFiles.length > maxFiles) {
|
||||
toast.error(`Maximálny počet súborov je ${maxFiles}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const response = await uploadFiles(entityType, entityId, newFiles, setUploadProgress);
|
||||
onFilesChange([...files, ...response.data]);
|
||||
toast.success(response.message || 'Súbory boli nahrané');
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
toast.error('Chyba pri nahrávaní súborov');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (fileId: string) => {
|
||||
setDeletingId(fileId);
|
||||
|
||||
try {
|
||||
await deleteFile(entityType, entityId, fileId);
|
||||
onFilesChange(files.filter((f) => f.id !== fileId));
|
||||
toast.success('Súbor bol vymazaný');
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('Chyba pri mazaní súboru');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (mimetype: string) => {
|
||||
if (isImageFile(mimetype)) return <Image className="h-5 w-5 text-blue-500" />;
|
||||
if (mimetype === 'application/pdf') return <FileText className="h-5 w-5 text-red-500" />;
|
||||
return <File className="h-5 w-5 text-gray-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-gray-400',
|
||||
isUploading && 'pointer-events-none'
|
||||
)}
|
||||
onClick={() => !disabled && !isUploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={accept}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="h-8 w-8 mx-auto animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-600">Nahrávam... {uploadProgress}%</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 max-w-xs mx-auto">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Presuňte súbory sem alebo <span className="text-blue-500 font-medium">kliknite</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Nahrané súbory ({files.length}/{maxFiles})
|
||||
</p>
|
||||
<ul className="divide-y divide-gray-200 border rounded-lg">
|
||||
{files.map((file) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
{isImageFile(file.mimetype) ? (
|
||||
<img
|
||||
src={getFilePreviewUrl(file.filepath)}
|
||||
alt={file.filename}
|
||||
className="h-10 w-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mimetype)
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.size)}
|
||||
{file.uploadedBy && ` • ${file.uploadedBy.name}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 flex-shrink-0">
|
||||
<a
|
||||
href={getDownloadUrl(entityType, file.id)}
|
||||
download
|
||||
className="p-1.5 text-gray-400 hover:text-blue-500 rounded"
|
||||
title="Stiahnuť"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(file.id)}
|
||||
disabled={deletingId === file.id}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded disabled:opacity-50"
|
||||
title="Vymazať"
|
||||
>
|
||||
{deletingId === file.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
frontend/src/components/ui/PendingFileUpload.tsx
Normal file
251
frontend/src/components/ui/PendingFileUpload.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Upload, File, Image, FileText, Loader2, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
uploadPendingFiles,
|
||||
deletePendingFile,
|
||||
getPendingFiles,
|
||||
formatFileSize,
|
||||
isImageFile,
|
||||
type PendingAttachment,
|
||||
} from '@/services/upload.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface PendingFileUploadProps {
|
||||
tempId: string;
|
||||
onFilesChange?: (files: PendingAttachment[]) => void;
|
||||
maxFiles?: number;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PendingFileUpload({
|
||||
tempId,
|
||||
onFilesChange,
|
||||
maxFiles = 10,
|
||||
accept = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv',
|
||||
disabled = false,
|
||||
className,
|
||||
}: PendingFileUploadProps) {
|
||||
const [files, setFiles] = useState<PendingAttachment[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load existing pending files on mount
|
||||
useEffect(() => {
|
||||
const loadPendingFiles = async () => {
|
||||
try {
|
||||
const response = await getPendingFiles(tempId);
|
||||
if (response.data && response.data.length > 0) {
|
||||
setFiles(response.data);
|
||||
onFilesChange?.(response.data);
|
||||
}
|
||||
} catch {
|
||||
// No pending files yet, that's fine
|
||||
}
|
||||
};
|
||||
loadPendingFiles();
|
||||
}, [tempId]);
|
||||
|
||||
const updateFiles = (newFiles: PendingAttachment[]) => {
|
||||
setFiles(newFiles);
|
||||
onFilesChange?.(newFiles);
|
||||
};
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
await handleUpload(droppedFiles);
|
||||
},
|
||||
[disabled, tempId, files, maxFiles]
|
||||
);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
await handleUpload(selectedFiles);
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (newFiles: File[]) => {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
// Check max files
|
||||
if (files.length + newFiles.length > maxFiles) {
|
||||
toast.error(`Maximálny počet súborov je ${maxFiles}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const response = await uploadPendingFiles(tempId, newFiles, setUploadProgress);
|
||||
const updatedFiles = [...files, ...response.data];
|
||||
updateFiles(updatedFiles);
|
||||
toast.success(response.message || 'Súbory boli nahrané');
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
toast.error('Chyba pri nahrávaní súborov');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file: PendingAttachment) => {
|
||||
setDeletingId(file.id);
|
||||
|
||||
try {
|
||||
await deletePendingFile(tempId, file.filename);
|
||||
const updatedFiles = files.filter((f) => f.id !== file.id);
|
||||
updateFiles(updatedFiles);
|
||||
toast.success('Súbor bol vymazaný');
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('Chyba pri mazaní súboru');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (mimetype: string) => {
|
||||
if (isImageFile(mimetype)) return <Image className="h-5 w-5 text-blue-500" />;
|
||||
if (mimetype === 'application/pdf') return <FileText className="h-5 w-5 text-red-500" />;
|
||||
return <File className="h-5 w-5 text-gray-500" />;
|
||||
};
|
||||
|
||||
// Get preview URL for pending files
|
||||
const getPreviewUrl = (file: PendingAttachment) => {
|
||||
return `/api${file.filepath}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-gray-400',
|
||||
isUploading && 'pointer-events-none'
|
||||
)}
|
||||
onClick={() => !disabled && !isUploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={accept}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled || isUploading}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="h-8 w-8 mx-auto animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-600">Nahrávam... {uploadProgress}%</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 max-w-xs mx-auto">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Presuňte súbory sem alebo <span className="text-blue-500 font-medium">kliknite</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Nahrané súbory ({files.length}/{maxFiles})
|
||||
</p>
|
||||
<ul className="divide-y divide-gray-200 border rounded-lg">
|
||||
{files.map((file) => (
|
||||
<li
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
{isImageFile(file.mimetype) ? (
|
||||
<img
|
||||
src={getPreviewUrl(file)}
|
||||
alt={file.filename}
|
||||
className="h-10 w-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mimetype)
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 flex-shrink-0">
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(file)}
|
||||
disabled={deletingId === file.id}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded disabled:opacity-50"
|
||||
title="Vymazať"
|
||||
>
|
||||
{deletingId === file.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/ui/SearchableSelect.tsx
Normal file
185
frontend/src/components/ui/SearchableSelect.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Search, ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SearchableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
options: SearchableOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = '-- Vyberte --',
|
||||
searchPlaceholder = 'Hľadať...',
|
||||
emptyMessage = 'Žiadne výsledky',
|
||||
disabled = false,
|
||||
className,
|
||||
}: SearchableSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter options based on search
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!search) return options;
|
||||
const searchLower = search.toLowerCase();
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchLower) ||
|
||||
opt.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
// Get selected option label
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
const handleSelect = (optValue: string) => {
|
||||
onChange(optValue);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-10 w-full rounded-md border border-input bg-background px-3 py-2',
|
||||
'focus-within:ring-2 focus-within:ring-ring cursor-pointer',
|
||||
isOpen && 'ring-2 ring-ring',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsOpen(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between h-full">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm truncate flex-1',
|
||||
!selectedOption && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="relative">
|
||||
<div className="absolute z-50 w-full mt-1 bg-popover border border-input rounded-md shadow-lg">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-background rounded border">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left hover:bg-accent flex flex-col',
|
||||
option.value === value && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,6 @@ export { Modal, ModalFooter } from './Modal';
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
||||
export { Spinner, LoadingOverlay } from './Spinner';
|
||||
export { UserSelect } from './UserSelect';
|
||||
export { SearchableSelect, type SearchableOption } from './SearchableSelect';
|
||||
export { FileUpload } from './FileUpload';
|
||||
export { PendingFileUpload } from './PendingFileUpload';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -5,8 +6,9 @@ import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { equipmentApi, type CreateEquipmentData } from '@/services/equipment.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import type { Equipment } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import { getFiles, generateTempId } from '@/services/upload.api';
|
||||
import type { Equipment, Attachment } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const equipmentSchema = z.object({
|
||||
@@ -37,6 +39,10 @@ interface EquipmentFormProps {
|
||||
export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!equipment;
|
||||
const [files, setFiles] = useState<Attachment[]>([]);
|
||||
|
||||
// Generate stable tempId for new equipment file uploads
|
||||
const tempId = useMemo(() => generateTempId(), []);
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-select'],
|
||||
@@ -48,6 +54,18 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||
queryFn: () => settingsApi.getEquipmentTypes(),
|
||||
});
|
||||
|
||||
// Load files when editing
|
||||
useQuery({
|
||||
queryKey: ['equipment-files', equipment?.id],
|
||||
queryFn: async () => {
|
||||
if (!equipment?.id) return { data: [] };
|
||||
const response = await getFiles('equipment', equipment.id);
|
||||
setFiles(response.data);
|
||||
return response;
|
||||
},
|
||||
enabled: isEditing,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -110,7 +128,8 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
// Include tempId for pending files
|
||||
createMutation.mutate({ ...cleanData, tempId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,6 +230,24 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||
{...register('notes')}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Prílohy
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<FileUpload
|
||||
entityType="equipment"
|
||||
entityId={equipment!.id}
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
/>
|
||||
) : (
|
||||
<PendingFileUpload
|
||||
tempId={tempId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register('active')} className="rounded" />
|
||||
<span className="text-sm">Aktívne</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { projectsApi, type CreateProjectData } from '@/services/projects.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import { type Zakazka } from '@/services/zakazky.api';
|
||||
import type { Project } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -25,9 +26,10 @@ type ProjectFormData = z.infer<typeof projectSchema>;
|
||||
interface ProjectFormProps {
|
||||
project: Project | null;
|
||||
onClose: () => void;
|
||||
externalZakazka?: Zakazka | null;
|
||||
}
|
||||
|
||||
export function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||
export function ProjectForm({ project, onClose, externalZakazka }: ProjectFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const isEditing = !!project;
|
||||
@@ -47,26 +49,44 @@ export function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||
queryFn: () => settingsApi.getUsers(),
|
||||
});
|
||||
|
||||
// Build default values based on editing mode or external zakazka
|
||||
const getDefaultValues = (): Partial<ProjectFormData> => {
|
||||
if (project) {
|
||||
return {
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
customerId: project.customerId || '',
|
||||
ownerId: project.ownerId,
|
||||
statusId: project.statusId,
|
||||
softDeadline: project.softDeadline?.split('T')[0] || '',
|
||||
hardDeadline: project.hardDeadline?.split('T')[0] || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (externalZakazka) {
|
||||
// Pre-fill from external zakazka
|
||||
return {
|
||||
name: `${externalZakazka.cislo} - ${externalZakazka.nazov}`,
|
||||
description: externalZakazka.poznamka || `Zákazník: ${externalZakazka.customer}\nVystavil: ${externalZakazka.vystavil}`,
|
||||
ownerId: user?.id || '',
|
||||
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||
hardDeadline: externalZakazka.datum_ukoncenia?.split('T')[0] || '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ownerId: user?.id || '',
|
||||
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ProjectFormData>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
defaultValues: project
|
||||
? {
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
customerId: project.customerId || '',
|
||||
ownerId: project.ownerId,
|
||||
statusId: project.statusId,
|
||||
softDeadline: project.softDeadline?.split('T')[0] || '',
|
||||
hardDeadline: project.hardDeadline?.split('T')[0] || '',
|
||||
}
|
||||
: {
|
||||
ownerId: user?.id || '',
|
||||
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||
},
|
||||
defaultValues: getDefaultValues(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { projectsApi } from '@/services/projects.api';
|
||||
import type { Project } from '@/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { zakazkyApi } from '@/services/zakazky.api';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Table,
|
||||
TableHeader,
|
||||
@@ -17,112 +16,149 @@ import {
|
||||
TableCell,
|
||||
Badge,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
ModalFooter,
|
||||
Select,
|
||||
} from '@/components/ui';
|
||||
import { ProjectForm } from './ProjectForm';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function ProjectsList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||
const [search, setSearch] = useState('');
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Project | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['projects', search],
|
||||
queryFn: () => projectsApi.getAll({ search, limit: 100 }),
|
||||
// Check if external DB is configured
|
||||
const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['zakazky-status'],
|
||||
queryFn: () => zakazkyApi.checkStatus(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => projectsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success('Projekt bol vymazaný');
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Chyba pri mazaní projektu');
|
||||
},
|
||||
// Get available years
|
||||
const { data: yearsData } = useQuery({
|
||||
queryKey: ['zakazky-years'],
|
||||
queryFn: () => zakazkyApi.getYears(),
|
||||
enabled: !!zakazkyStatus?.data?.configured,
|
||||
});
|
||||
|
||||
const handleEdit = (project: Project) => {
|
||||
setEditingProject(project);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
// Get zakazky for selected year
|
||||
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
|
||||
queryKey: ['zakazky', selectedYear, search],
|
||||
queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined),
|
||||
enabled: !!zakazkyStatus?.data?.configured,
|
||||
});
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingProject(null);
|
||||
};
|
||||
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
|
||||
const yearOptions = (yearsData?.data || []).map((year) => ({
|
||||
value: String(year),
|
||||
label: String(year),
|
||||
}));
|
||||
|
||||
if (statusLoading) {
|
||||
return <LoadingOverlay />;
|
||||
}
|
||||
|
||||
if (!isExternalDbConfigured) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Zákazky</h1>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<ExternalLink className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Externá databáza nie je nakonfigurovaná</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Pre zobrazenie zákaziek je potrebné nakonfigurovať pripojenie k externej databáze.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Nastavte premenné EXTERNAL_DB_* v .env súbore.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Projekty</h1>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nový projekt
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Zákazky</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
Zákazky z externej databázy
|
||||
</CardTitle>
|
||||
<div className="flex items-end gap-4 mt-4">
|
||||
<Select
|
||||
id="year"
|
||||
label="Rok"
|
||||
value={String(selectedYear)}
|
||||
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
||||
options={yearOptions}
|
||||
className="w-32"
|
||||
/>
|
||||
<div className="flex-1 max-w-sm">
|
||||
<Input
|
||||
placeholder="Hľadať projekty..."
|
||||
id="search"
|
||||
label="Hľadať"
|
||||
placeholder="Hľadať zákazky..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
{zakazkyLoading ? (
|
||||
<LoadingOverlay />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Číslo</TableHead>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Zákazník</TableHead>
|
||||
<TableHead>Vlastník</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead>Termín</TableHead>
|
||||
<TableHead className="text-right">Akcie</TableHead>
|
||||
<TableHead>Vystavené</TableHead>
|
||||
<TableHead>Ukončenie</TableHead>
|
||||
<TableHead>Vystavil</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-medium">{project.name}</TableCell>
|
||||
<TableCell>{project.customer?.name || '-'}</TableCell>
|
||||
<TableCell>{project.owner.name}</TableCell>
|
||||
{zakazkyData?.data?.map((zakazka) => (
|
||||
<TableRow key={zakazka.id}>
|
||||
<TableCell className="font-mono font-medium">{zakazka.cislo}</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<div className="truncate" title={zakazka.nazov}>
|
||||
{zakazka.nazov}
|
||||
</div>
|
||||
{zakazka.poznamka && (
|
||||
<div className="text-xs text-muted-foreground truncate" title={zakazka.poznamka}>
|
||||
{zakazka.poznamka}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{zakazka.customer}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={project.status.color}>{project.status.name}</Badge>
|
||||
<Badge color={zakazka.uzavreta ? 'gray' : 'green'}>
|
||||
{zakazka.uzavreta ? 'Uzavretá' : 'Otvorená'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.hardDeadline ? formatDate(project.hardDeadline) : '-'}
|
||||
{zakazka.datum_vystavenia
|
||||
? formatDate(zakazka.datum_vystavenia)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(project)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(project)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<TableCell>
|
||||
{zakazka.datum_ukoncenia
|
||||
? formatDate(zakazka.datum_ukoncenia)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{zakazka.vystavil}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
{(!zakazkyData?.data || zakazkyData.data.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
Žiadne projekty
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
Žiadne zákazky pre rok {selectedYear}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -131,35 +167,6 @@ export function ProjectsList() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
onClose={handleCloseForm}
|
||||
title={editingProject ? 'Upraviť projekt' : 'Nový projekt'}
|
||||
size="lg"
|
||||
>
|
||||
<ProjectForm project={editingProject} onClose={handleCloseForm} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
title="Potvrdiť vymazanie"
|
||||
>
|
||||
<p>Naozaj chcete vymazať projekt "{deleteConfirm?.name}"?</p>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Zrušiť
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
>
|
||||
Vymazať
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -5,8 +6,9 @@ import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { rmaApi, type CreateRMAData, type UpdateRMAData } from '@/services/rma.api';
|
||||
import { customersApi } from '@/services/customers.api';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import type { RMA } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||
import { getFiles, generateTempId } from '@/services/upload.api';
|
||||
import type { RMA, Attachment } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const rmaSchema = z.object({
|
||||
@@ -44,12 +46,28 @@ interface RMAFormProps {
|
||||
export function RMAForm({ rma, onClose }: RMAFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!rma;
|
||||
const [files, setFiles] = useState<Attachment[]>([]);
|
||||
|
||||
// Generate stable tempId for new RMA file uploads
|
||||
const tempId = useMemo(() => generateTempId(), []);
|
||||
|
||||
const { data: customersData } = useQuery({
|
||||
queryKey: ['customers-select'],
|
||||
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||
});
|
||||
|
||||
// Load files when editing
|
||||
useQuery({
|
||||
queryKey: ['rma-files', rma?.id],
|
||||
queryFn: async () => {
|
||||
if (!rma?.id) return { data: [] };
|
||||
const response = await getFiles('rma', rma.id);
|
||||
setFiles(response.data);
|
||||
return response;
|
||||
},
|
||||
enabled: isEditing,
|
||||
});
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['rma-statuses'],
|
||||
queryFn: () => settingsApi.getRMAStatuses(),
|
||||
@@ -140,7 +158,8 @@ export function RMAForm({ rma, onClose }: RMAFormProps) {
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData as CreateRMAData);
|
||||
// Include tempId for pending files
|
||||
createMutation.mutate({ ...cleanData, tempId } as CreateRMAData);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,6 +315,24 @@ export function RMAForm({ rma, onClose }: RMAFormProps) {
|
||||
{...register('resolutionNotes')}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Prílohy (fotky, doklady)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<FileUpload
|
||||
entityType="rma"
|
||||
entityId={rma!.id}
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
/>
|
||||
) : (
|
||||
<PendingFileUpload
|
||||
tempId={tempId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { tasksApi, type CreateTaskData } from '@/services/tasks.api';
|
||||
import { projectsApi } from '@/services/projects.api';
|
||||
import { settingsApi } from '@/services/settings.api'; // Pre statusy a priority
|
||||
import type { Task } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter, UserSelect } from '@/components/ui';
|
||||
import { settingsApi } from '@/services/settings.api';
|
||||
import { zakazkyApi } from '@/services/zakazky.api';
|
||||
import { getFiles, generateTempId } from '@/services/upload.api';
|
||||
import type { Task, Attachment } from '@/types';
|
||||
import { Button, Input, Textarea, Select, ModalFooter, UserSelect, SearchableSelect, FileUpload, PendingFileUpload } from '@/components/ui';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, 'Názov je povinný'),
|
||||
description: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
statusId: z.string().optional(),
|
||||
priorityId: z.string().optional(),
|
||||
deadline: z.string().optional(),
|
||||
@@ -35,10 +35,15 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
task?.assignees?.map((a) => a.userId) || []
|
||||
);
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ['projects-select'],
|
||||
queryFn: () => projectsApi.getAll({ limit: 1000 }),
|
||||
});
|
||||
// State pre súbory
|
||||
const [files, setFiles] = useState<Attachment[]>([]);
|
||||
|
||||
// State pre externé zákazky
|
||||
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
||||
const [selectedZakazkaId, setSelectedZakazkaId] = useState<string>('');
|
||||
|
||||
// Generate stable tempId for new task file uploads
|
||||
const tempId = useMemo(() => generateTempId(), []);
|
||||
|
||||
const { data: statusesData } = useQuery({
|
||||
queryKey: ['task-statuses'],
|
||||
@@ -50,10 +55,42 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
queryFn: () => settingsApi.getPriorities(),
|
||||
});
|
||||
|
||||
// Check if external DB is configured
|
||||
const { data: zakazkyStatus } = useQuery({
|
||||
queryKey: ['zakazky-status'],
|
||||
queryFn: () => zakazkyApi.checkStatus(),
|
||||
});
|
||||
|
||||
// Get available years for external zákazky
|
||||
const { data: yearsData } = useQuery({
|
||||
queryKey: ['zakazky-years'],
|
||||
queryFn: () => zakazkyApi.getYears(),
|
||||
enabled: !!zakazkyStatus?.data?.configured,
|
||||
});
|
||||
|
||||
// Get external zákazky for selected year
|
||||
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
|
||||
queryKey: ['zakazky', selectedYear],
|
||||
queryFn: () => zakazkyApi.getAll(selectedYear),
|
||||
enabled: !!zakazkyStatus?.data?.configured,
|
||||
});
|
||||
|
||||
// Load files when editing
|
||||
useQuery({
|
||||
queryKey: ['task-files', task?.id],
|
||||
queryFn: async () => {
|
||||
if (!task?.id) return { data: [] };
|
||||
const response = await getFiles('task', task.id);
|
||||
setFiles(response.data);
|
||||
return response;
|
||||
},
|
||||
enabled: isEditing,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
@@ -61,7 +98,6 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
? {
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
projectId: task.projectId || '',
|
||||
statusId: task.statusId,
|
||||
priorityId: task.priorityId,
|
||||
deadline: task.deadline?.split('T')[0] || '',
|
||||
@@ -69,13 +105,22 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
: {
|
||||
title: '',
|
||||
description: '',
|
||||
projectId: '',
|
||||
statusId: '',
|
||||
priorityId: '',
|
||||
deadline: '',
|
||||
},
|
||||
});
|
||||
|
||||
// When external zákazka is selected, only set deadline if available
|
||||
useEffect(() => {
|
||||
if (selectedZakazkaId && zakazkyData?.data) {
|
||||
const zakazka = zakazkyData.data.find((z) => String(z.id) === selectedZakazkaId);
|
||||
if (zakazka?.datum_ukoncenia) {
|
||||
setValue('deadline', zakazka.datum_ukoncenia.split('T')[0]);
|
||||
}
|
||||
}
|
||||
}, [selectedZakazkaId, zakazkyData, setValue]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTaskData) => tasksApi.create(data),
|
||||
onSuccess: () => {
|
||||
@@ -109,12 +154,9 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
const onSubmit = (data: TaskFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
projectId: data.projectId || undefined,
|
||||
statusId: data.statusId || undefined,
|
||||
priorityId: data.priorityId || undefined,
|
||||
deadline: data.deadline || undefined,
|
||||
// Pre create: undefined ak prázdne (backend priradí default)
|
||||
// Pre update: vždy poslať pole (aj prázdne) aby sa aktualizovali assignees
|
||||
assigneeIds: isEditing ? selectedAssignees : (selectedAssignees.length > 0 ? selectedAssignees : undefined),
|
||||
};
|
||||
|
||||
@@ -123,25 +165,61 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(cleanData);
|
||||
} else {
|
||||
createMutation.mutate(cleanData);
|
||||
createMutation.mutate({ ...cleanData, tempId });
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const projectOptions = projectsData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||
const priorityOptions = prioritiesData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||
const yearOptions = (yearsData?.data || []).map((year) => ({ value: String(year), label: String(year) }));
|
||||
|
||||
// Prepare zákazky options for SearchableSelect
|
||||
const zakazkyOptions = useMemo(() => {
|
||||
return (zakazkyData?.data || []).map((z) => ({
|
||||
value: String(z.id),
|
||||
label: z.nazov,
|
||||
description: `${z.cislo} · ${z.customer}`,
|
||||
}));
|
||||
}, [zakazkyData]);
|
||||
|
||||
// Pripraviť počiatočných používateľov pre editáciu (už priradení)
|
||||
const initialAssignees = task?.assignees?.map((a) => ({
|
||||
id: a.userId,
|
||||
name: a.user?.name || '',
|
||||
email: a.user?.email || '',
|
||||
})) || [];
|
||||
|
||||
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* External zákazky selector */}
|
||||
{isExternalDbConfigured && (
|
||||
<div className="grid gap-3 md:grid-cols-[120px_1fr]">
|
||||
<Select
|
||||
id="year"
|
||||
label="Rok"
|
||||
value={String(selectedYear)}
|
||||
onChange={(e) => {
|
||||
setSelectedYear(Number(e.target.value));
|
||||
setSelectedZakazkaId('');
|
||||
}}
|
||||
options={yearOptions}
|
||||
/>
|
||||
<SearchableSelect
|
||||
label="Zákazka"
|
||||
options={zakazkyOptions}
|
||||
value={selectedZakazkaId}
|
||||
onChange={setSelectedZakazkaId}
|
||||
placeholder="-- Vyberte zákazku --"
|
||||
searchPlaceholder="Hľadať podľa čísla, názvu alebo zákazníka..."
|
||||
emptyMessage={zakazkyLoading ? 'Načítavam...' : 'Žiadne zákazky'}
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id="title"
|
||||
label="Názov *"
|
||||
@@ -156,13 +234,7 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
id="projectId"
|
||||
label="Projekt"
|
||||
options={[{ value: '', label: '-- Bez projektu --' }, ...projectOptions]}
|
||||
{...register('projectId')}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Select
|
||||
id="statusId"
|
||||
label="Stav"
|
||||
@@ -191,6 +263,24 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||
placeholder="Vyhľadať používateľa..."
|
||||
/>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Prílohy
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<FileUpload
|
||||
entityType="task"
|
||||
entityId={task!.id}
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
/>
|
||||
) : (
|
||||
<PendingFileUpload
|
||||
tempId={tempId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Zrušiť
|
||||
|
||||
@@ -92,7 +92,6 @@ export function TasksList() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Názov</TableHead>
|
||||
<TableHead>Projekt</TableHead>
|
||||
<TableHead>Zadal</TableHead>
|
||||
<TableHead>Stav</TableHead>
|
||||
<TableHead>Priorita</TableHead>
|
||||
@@ -112,7 +111,6 @@ export function TasksList() {
|
||||
{task.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{task.project?.name || '-'}</TableCell>
|
||||
<TableCell>{task.createdBy?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={task.status.color}>{task.status.name}</Badge>
|
||||
@@ -141,7 +139,7 @@ export function TasksList() {
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
Žiadne úlohy
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CreateEquipmentData {
|
||||
description?: string;
|
||||
notes?: string;
|
||||
active?: boolean;
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export type UpdateEquipmentData = Partial<CreateEquipmentData>;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CreateRMAData {
|
||||
statusId: string;
|
||||
requiresApproval?: boolean;
|
||||
assignedToId?: string;
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export interface UpdateRMAData {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CreateTaskData {
|
||||
priorityId?: string;
|
||||
deadline?: string;
|
||||
assigneeIds?: string[];
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export type UpdateTaskData = Partial<CreateTaskData>;
|
||||
|
||||
138
frontend/src/services/upload.api.ts
Normal file
138
frontend/src/services/upload.api.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { api, get, del } from './api';
|
||||
import type { ApiResponse, Attachment } from '@/types';
|
||||
|
||||
export type EntityType = 'equipment' | 'rma' | 'task';
|
||||
|
||||
// Pending attachment type (before entity is saved)
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
isPending: true;
|
||||
}
|
||||
|
||||
// Generate unique temp ID for pending uploads
|
||||
export function generateTempId(): string {
|
||||
return `temp-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
// Upload files for existing entity
|
||||
export async function uploadFiles(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
files: File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<Attachment[]>> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await api.post<ApiResponse<Attachment[]>>(
|
||||
`/${entityType}/${entityId}/files`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Upload pending files (for new entities before they are saved)
|
||||
export async function uploadPendingFiles(
|
||||
tempId: string,
|
||||
files: File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<PendingAttachment[]>> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await api.post<ApiResponse<PendingAttachment[]>>(
|
||||
`/files/pending/${tempId}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get pending files
|
||||
export function getPendingFiles(tempId: string): Promise<ApiResponse<PendingAttachment[]>> {
|
||||
return get<PendingAttachment[]>(`/files/pending/${tempId}`);
|
||||
}
|
||||
|
||||
// Delete pending file
|
||||
export async function deletePendingFile(tempId: string, filename: string): Promise<ApiResponse<null>> {
|
||||
return del<null>(`/files/pending/${tempId}/${filename}`);
|
||||
}
|
||||
|
||||
// Get files for entity
|
||||
export function getFiles(entityType: EntityType, entityId: string): Promise<ApiResponse<Attachment[]>> {
|
||||
return get<Attachment[]>(`/${entityType}/${entityId}/files`);
|
||||
}
|
||||
|
||||
// Delete file
|
||||
export function deleteFile(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
fileId: string
|
||||
): Promise<ApiResponse<null>> {
|
||||
return del<null>(`/${entityType}/${entityId}/files/${fileId}`);
|
||||
}
|
||||
|
||||
// Download file URL
|
||||
export function getDownloadUrl(entityType: EntityType, fileId: string): string {
|
||||
return `/api/files/${entityType}/${fileId}/download`;
|
||||
}
|
||||
|
||||
// Get file preview URL (for images)
|
||||
export function getFilePreviewUrl(filepath: string): string {
|
||||
// filepath je vo formáte /uploads/equipment/filename.jpg
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Format file size
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// Check if file is image
|
||||
export function isImageFile(mimetype: string): boolean {
|
||||
return mimetype.startsWith('image/');
|
||||
}
|
||||
|
||||
// Get file icon based on mimetype
|
||||
export function getFileIcon(mimetype: string): string {
|
||||
if (mimetype.startsWith('image/')) return 'image';
|
||||
if (mimetype === 'application/pdf') return 'file-text';
|
||||
if (mimetype.includes('word')) return 'file-text';
|
||||
if (mimetype.includes('excel') || mimetype.includes('spreadsheet')) return 'file-spreadsheet';
|
||||
if (mimetype.includes('text')) return 'file-text';
|
||||
return 'file';
|
||||
}
|
||||
43
frontend/src/services/zakazky.api.ts
Normal file
43
frontend/src/services/zakazky.api.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { get } from './api';
|
||||
|
||||
// Zakazka interface matching backend
|
||||
export interface Zakazka {
|
||||
id: number;
|
||||
id_stav_zakazky: number;
|
||||
cislo: string;
|
||||
datum_vystavenia: string | null;
|
||||
datum_ukoncenia: string | null;
|
||||
customer: string;
|
||||
nazov: string;
|
||||
poznamka: string | null;
|
||||
vystavil: string;
|
||||
uzavreta: boolean;
|
||||
}
|
||||
|
||||
export interface ZakazkyStatus {
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
// Check if external DB is configured
|
||||
export const checkZakazkyStatus = () =>
|
||||
get<ZakazkyStatus>('/zakazky/status');
|
||||
|
||||
// Get available years
|
||||
export const getAvailableYears = () =>
|
||||
get<number[]>('/zakazky/years');
|
||||
|
||||
// Get zakazky by year
|
||||
export const getZakazky = (rok: number, search?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('rok', String(rok));
|
||||
if (search) {
|
||||
params.append('search', search);
|
||||
}
|
||||
return get<Zakazka[]>(`/zakazky?${params.toString()}`);
|
||||
};
|
||||
|
||||
export const zakazkyApi = {
|
||||
checkStatus: checkZakazkyStatus,
|
||||
getYears: getAvailableYears,
|
||||
getAll: getZakazky,
|
||||
};
|
||||
@@ -269,6 +269,18 @@ export interface SystemSetting {
|
||||
dataType: 'string' | 'number' | 'boolean' | 'json';
|
||||
}
|
||||
|
||||
// File Attachment
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
uploadedById: string;
|
||||
uploadedBy?: Pick<User, 'id' | 'name'>;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// API Response
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user