From cbdd952bc10fe5a29c23c8206e22d491b570bf02 Mon Sep 17 00:00:00 2001 From: pettrop Date: Tue, 3 Feb 2026 11:20:17 +0100 Subject: [PATCH] Prepojenie na externu DB, projekt-zakazky --- backend/.env.example | 8 + backend/package-lock.json | 314 +++++++++- backend/package.json | 4 + backend/prisma/schema.prisma | 20 + backend/src/config/env.ts | 7 + .../src/controllers/equipment.controller.ts | 6 + backend/src/controllers/rma.controller.ts | 6 + backend/src/controllers/tasks.controller.ts | 6 + backend/src/controllers/upload.controller.ts | 586 ++++++++++++++++++ backend/src/controllers/zakazky.controller.ts | 48 ++ backend/src/index.ts | 31 +- .../src/middleware/activityLog.middleware.ts | 2 +- backend/src/middleware/upload.middleware.ts | 164 +++++ backend/src/routes/equipment.routes.ts | 7 + backend/src/routes/index.ts | 4 + backend/src/routes/rma.routes.ts | 7 + backend/src/routes/tasks.routes.ts | 7 + backend/src/routes/upload.routes.ts | 20 + backend/src/routes/zakazky.routes.ts | 21 + backend/src/services/externalDb.service.ts | 173 ++++++ frontend/src/components/layout/Sidebar.tsx | 2 +- frontend/src/components/ui/FileUpload.tsx | 237 +++++++ .../src/components/ui/PendingFileUpload.tsx | 251 ++++++++ .../src/components/ui/SearchableSelect.tsx | 185 ++++++ frontend/src/components/ui/index.ts | 3 + .../src/pages/equipment/EquipmentForm.tsx | 43 +- frontend/src/pages/projects/ProjectForm.tsx | 50 +- frontend/src/pages/projects/ProjectsList.tsx | 195 +++--- frontend/src/pages/rma/RMAForm.tsx | 43 +- frontend/src/pages/tasks/TaskForm.tsx | 140 ++++- frontend/src/pages/tasks/TasksList.tsx | 4 +- frontend/src/services/equipment.api.ts | 1 + frontend/src/services/rma.api.ts | 1 + frontend/src/services/tasks.api.ts | 1 + frontend/src/services/upload.api.ts | 138 +++++ frontend/src/services/zakazky.api.ts | 43 ++ frontend/src/types/index.ts | 12 + 37 files changed, 2641 insertions(+), 149 deletions(-) create mode 100644 backend/src/controllers/upload.controller.ts create mode 100644 backend/src/controllers/zakazky.controller.ts create mode 100644 backend/src/middleware/upload.middleware.ts create mode 100644 backend/src/routes/upload.routes.ts create mode 100644 backend/src/routes/zakazky.routes.ts create mode 100644 backend/src/services/externalDb.service.ts create mode 100644 frontend/src/components/ui/FileUpload.tsx create mode 100644 frontend/src/components/ui/PendingFileUpload.tsx create mode 100644 frontend/src/components/ui/SearchableSelect.tsx create mode 100644 frontend/src/services/upload.api.ts create mode 100644 frontend/src/services/zakazky.api.ts diff --git a/backend/.env.example b/backend/.env.example index 24d1153..e104533 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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="" diff --git a/backend/package-lock.json b/backend/package-lock.json index 221e260..ed6df1c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" diff --git a/backend/package.json b/backend/package.json index b2dd942..4bfa2c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9b1958f..3710b78 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 2b1d729..de3d380 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -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', diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index 69e1136..cbd9494 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -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 => { 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 }); } diff --git a/backend/src/controllers/rma.controller.ts b/backend/src/controllers/rma.controller.ts index 7a4a3bd..884f71f 100644 --- a/backend/src/controllers/rma.controller.ts +++ b/backend/src/controllers/rma.controller.ts @@ -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 { const today = new Date(); @@ -192,6 +193,11 @@ export const createRMA = async (req: AuthRequest, res: Response): Promise }, }); + // 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 }); } diff --git a/backend/src/controllers/tasks.controller.ts b/backend/src/controllers/tasks.controller.ts index c696bc0..caac958 100644 --- a/backend/src/controllers/tasks.controller.ts +++ b/backend/src/controllers/tasks.controller.ts @@ -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 => { try { @@ -168,6 +169,11 @@ export const createTask = async (req: AuthRequest, res: Response): Promise }); } + // 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 }); } diff --git a/backend/src/controllers/upload.controller.ts b/backend/src/controllers/upload.controller.ts new file mode 100644 index 0000000..a0d61a1 --- /dev/null +++ b/backend/src/controllers/upload.controller.ts @@ -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(); + +// 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); + } +}; diff --git a/backend/src/controllers/zakazky.controller.ts b/backend/src/controllers/zakazky.controller.ts new file mode 100644 index 0000000..c92ff39 --- /dev/null +++ b/backend/src/controllers/zakazky.controller.ts @@ -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 => { + const isConfigured = externalDbService.isConfigured(); + successResponse(res, { configured: isConfigured }); +}; + +// Get zakazky by year +export const getZakazky = async (req: AuthRequest, res: Response): Promise => { + 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 => { + const currentYear = new Date().getFullYear(); + const years = []; + + for (let i = 0; i <= 5; i++) { + years.push(currentYear - i); + } + + successResponse(res, years); +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 5a16866..47fd838 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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')); diff --git a/backend/src/middleware/activityLog.middleware.ts b/backend/src/middleware/activityLog.middleware.ts index a766099..b3cdecc 100644 --- a/backend/src/middleware/activityLog.middleware.ts +++ b/backend/src/middleware/activityLog.middleware.ts @@ -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 ( diff --git a/backend/src/middleware/upload.middleware.ts b/backend/src/middleware/upload.middleware.ts new file mode 100644 index 0000000..42bf72c --- /dev/null +++ b/backend/src/middleware/upload.middleware.ts @@ -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); +}; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index 77b1703..0dea964 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -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; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 919f8d8..8b4f3a4 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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; diff --git a/backend/src/routes/rma.routes.ts b/backend/src/routes/rma.routes.ts index 1786067..617fc86 100644 --- a/backend/src/routes/rma.routes.ts +++ b/backend/src/routes/rma.routes.ts @@ -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; diff --git a/backend/src/routes/tasks.routes.ts b/backend/src/routes/tasks.routes.ts index 9210463..90d0c13 100644 --- a/backend/src/routes/tasks.routes.ts +++ b/backend/src/routes/tasks.routes.ts @@ -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; diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts new file mode 100644 index 0000000..78aeff4 --- /dev/null +++ b/backend/src/routes/upload.routes.ts @@ -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; diff --git a/backend/src/routes/zakazky.routes.ts b/backend/src/routes/zakazky.routes.ts new file mode 100644 index 0000000..d797f96 --- /dev/null +++ b/backend/src/routes/zakazky.routes.ts @@ -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; diff --git a/backend/src/services/externalDb.service.ts b/backend/src/services/externalDb.service.ts new file mode 100644 index 0000000..3cf7ba8 --- /dev/null +++ b/backend/src/services/externalDb.service.ts @@ -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 (query: string, params?: unknown[]): Promise => { + // 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 => { + try { + // Call the stored function using safe read-only executor + const rows = await executeReadOnlyQuery( + '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 => { + const zakazky = await getZakazkyByYear(rok); + return zakazky.find((z) => z.id === id) || null; +}; + +// Search zakazky +export const searchZakazky = async (rok: number, search: string): Promise => { + 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 => { + if (pool) { + await pool.end(); + pool = null; + } +}; + +export const externalDbService = { + isConfigured: isExternalDbConfigured, + getZakazkyByYear, + getZakazkaById, + searchZakazky, + close: closeExternalDb, +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index fce6551..ec9d3e7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, diff --git a/frontend/src/components/ui/FileUpload.tsx b/frontend/src/components/ui/FileUpload.tsx new file mode 100644 index 0000000..eb7f476 --- /dev/null +++ b/frontend/src/components/ui/FileUpload.tsx @@ -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(null); + const fileInputRef = useRef(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) => { + 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 ; + if (mimetype === 'application/pdf') return ; + return ; + }; + + return ( +
+ {/* Drop zone */} +
!disabled && !isUploading && fileInputRef.current?.click()} + > + + + {isUploading ? ( +
+ +

Nahrávam... {uploadProgress}%

+
+
+
+
+ ) : ( + <> + +

+ Presuňte súbory sem alebo kliknite +

+

+ Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel +

+ + )} +
+ + {/* File list */} + {files.length > 0 && ( +
+

+ Nahrané súbory ({files.length}/{maxFiles}) +

+
    + {files.map((file) => ( +
  • +
    + {isImageFile(file.mimetype) ? ( + {file.filename} + ) : ( + getFileIcon(file.mimetype) + )} +
    +

    + {file.filename} +

    +

    + {formatFileSize(file.size)} + {file.uploadedBy && ` • ${file.uploadedBy.name}`} +

    +
    +
    + +
    + + + + {!disabled && ( + + )} +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/PendingFileUpload.tsx b/frontend/src/components/ui/PendingFileUpload.tsx new file mode 100644 index 0000000..2b54ba0 --- /dev/null +++ b/frontend/src/components/ui/PendingFileUpload.tsx @@ -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([]); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [deletingId, setDeletingId] = useState(null); + const fileInputRef = useRef(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) => { + 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 ; + if (mimetype === 'application/pdf') return ; + return ; + }; + + // Get preview URL for pending files + const getPreviewUrl = (file: PendingAttachment) => { + return `/api${file.filepath}`; + }; + + return ( +
+ {/* Drop zone */} +
!disabled && !isUploading && fileInputRef.current?.click()} + > + + + {isUploading ? ( +
+ +

Nahrávam... {uploadProgress}%

+
+
+
+
+ ) : ( + <> + +

+ Presuňte súbory sem alebo kliknite +

+

+ Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel +

+ + )} +
+ + {/* File list */} + {files.length > 0 && ( +
+

+ Nahrané súbory ({files.length}/{maxFiles}) +

+
    + {files.map((file) => ( +
  • +
    + {isImageFile(file.mimetype) ? ( + {file.filename} + ) : ( + getFileIcon(file.mimetype) + )} +
    +

    + {file.filename} +

    +

    + {formatFileSize(file.size)} +

    +
    +
    + +
    + {!disabled && ( + + )} +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/SearchableSelect.tsx b/frontend/src/components/ui/SearchableSelect.tsx new file mode 100644 index 0000000..bdee7db --- /dev/null +++ b/frontend/src/components/ui/SearchableSelect.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+ {label && ( + + )} + +
{ + if (!disabled) { + setIsOpen(true); + setTimeout(() => inputRef.current?.focus(), 0); + } + }} + > +
+ + {selectedOption?.label || placeholder} + +
+ {value && ( + + )} + +
+
+
+ + {/* Dropdown */} + {isOpen && !disabled && ( +
+
+ {/* Search input */} +
+
+ + setSearch(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={searchPlaceholder} + className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground" + /> +
+
+ + {/* Options list */} +
+ {filteredOptions.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + filteredOptions.map((option) => ( + + )) + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 8204cd1..63a272c 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -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'; diff --git a/frontend/src/pages/equipment/EquipmentForm.tsx b/frontend/src/pages/equipment/EquipmentForm.tsx index 8fba393..0b8fb03 100644 --- a/frontend/src/pages/equipment/EquipmentForm.tsx +++ b/frontend/src/pages/equipment/EquipmentForm.tsx @@ -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([]); + + // 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')} /> +
+ + {isEditing ? ( + + ) : ( + + )} +
+