commit 33800292aa9a7638ab8f9888d9cd8359df0af21e Author: Danil Suhomlinov Date: Mon Jun 8 17:42:45 2026 +0300 chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene) - apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..faa93f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/node_modules +**/dist +**/.vite +**/.turbo +.git +.gitignore +*.log +**/.env +**/.env.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79af08c --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# PostgreSQL +DATABASE_URL="postgresql://hrm:hrm_password@localhost:5432/hrm_medpark?schema=public" + +# Keycloak +KEYCLOAK_URL="http://localhost:8080" +KEYCLOAK_REALM="medpark" +KEYCLOAK_CLIENT_ID="hrm-api" +KEYCLOAK_CLIENT_SECRET="change-me" + +# Redis (BullMQ) +REDIS_HOST="localhost" +REDIS_PORT=6379 + +# MinIO (S3) +MINIO_ENDPOINT="localhost" +MINIO_PORT=9000 +MINIO_ACCESS_KEY="minioadmin" +MINIO_SECRET_KEY="minioadmin" +MINIO_BUCKET="hrm-docs" + +# n8n webhook base URL +N8N_WEBHOOK_BASE="http://localhost:5678/webhook" + +# App +PORT=3001 +NODE_ENV="development" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bb8a8ab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize line endings. Keep LF for files consumed inside Linux containers. +* text=auto + +Dockerfile text eol=lf +*.sh text eol=lf +*.conf text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbd31c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +build/ +apps/web/dist/ +apps/api/dist/ +*.tsbuildinfo + +# Vite / caches +.vite/ +**/.vite/ +.turbo/ + +# Secrets — never commit. Keep only *.example +.env +.env.* +!.env.example +apps/api/.env +apps/api/.env.* +!apps/api/.env.example +apps/web/.env +apps/web/.env.* +!apps/web/.env.example + +# Runtime data / uploads +storage/ +apps/api/storage/ +*.sqlite +*.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/* +!.vscode/extensions.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..29e010f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,502 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# HRM Medpark — Полное описание системы + +> HR-management система для **Medpark International Hospital** (Кишинёв, Молдова), заменяющая Excel/MS Forms процессы для отдела кадров и контроля качества медицинского персонала. +> +> UI — только на румынском (`ro`). Backend сообщения / валидации — также на румынском. + +--- + +## 1. Архитектура + +### Monorepo (pnpm workspace) + +``` +hrm-medpark/ +├── apps/ +│ ├── api/ ← NestJS + Prisma + PostgreSQL +│ └── web/ ← React + Vite + Mantine +├── docker-compose.yml ← Postgres, Redis, Keycloak, MinIO +└── package.json ← root scripts (api:dev, web:dev, db:migrate) +``` + +### Stack + +| Слой | Технологии | +|------------|------------------------------------------------------------------------------------| +| Backend | NestJS 10 (Fastify), Prisma 5, PostgreSQL 16, BullMQ (Redis), nestjs-i18n, Passport-JWT (Keycloak JWKS) | +| Frontend | React 18, Vite, Mantine v7, React Router v6, TanStack Query v5, react-hook-form + Zod, dayjs | +| Auth | Keycloak (внешний IdP), JWT валидация через JWKS | +| Storage | MinIO (S3-совместимый) для DOCX-документов | +| Notif | n8n webhooks (cron-based reminders) | +| Документы | `docx` (npm) — рендер из TipTap-JSON шаблонов (`AnexaTemplate`), редактируются в `/admin/anexa-templates` | + +### Глобальный API префикс + +Все REST-эндпоинты под `/api/v1/...` (через `app.setGlobalPrefix('api/v1')`). +Vite proxy на dev: `/api → http://localhost:3001`. + +--- + +## 2. Аутентификация + +- Токены выдаёт Keycloak. +- `KeycloakStrategy` (passport-jwt) валидирует через JWKS. +- Роли извлекаются из `realm_access.roles` и `resource_access[clientId].roles`. +- Если в токене нет ни одной из 7 HRM-ролей — `UnauthorizedException`. + +### Роли + +| Role | Назначение | +|-------------------|------------------------------------------------------------| +| `hr_admin` | Полный CRUD по сотрудникам, документам, кампаниям, риск-картам | +| `hr_specialist` | CREATE/UPDATE по основным сущностям, без удаления | +| `manager` | Редактирование форм оценки своих подчинённых | +| `nursing_director`| Утверждение финальной категории при оценке | +| `quality_auditor` | Заполняет блоки A-D в формах оценки | +| `medic_familie` | Выдаёт вердикт по контролю медиц. (apt / inapt / etc.) | +| `employee` | Read-only доступ к собственным данным | + +`@Roles(...)` декоратор + `RolesGuard` на каждом контроллере. + +--- + +## 3. База данных (Prisma, 20 моделей) + +Ключевые сущности: + +``` +Employee (1) ──┬── (N) IdentityDocument + ├── (N) FamilyMember + ├── (N) Education + ├── (N) Qualification + ├── (N) Training + ├── (N) DisciplinarySanction + ├── (N) EmploymentContract + ├── (1) Benefit ← upsert (one-to-one) + ├── (1) EmployeeMedicalProfile ← upsert + ├── (N) MedicalCheckup + └── (N) PerformanceEvaluation + +Department (self-referencing tree, parentId) — adjacency list +WorkplaceRiskCard (1) ── (N) EmployeeMedicalProfile ← FK +PerformanceCampaign (1) ── (N) PerformanceEvaluation +``` + +### Reference (read-only) таблицы: +- `DisabilityGrade`, `TaxExemption`, `WorkSchedule` + +### Поля типа `@db.Date` — используются для дат без времени (рождение, экспирация). +### `@db.Decimal` — для денежных и физических величин (зарплата, дозы радиации). + +### Audit log +- `AuditService.logRead(...)` и `logChange({ action: 'CREATE'|'UPDATE'|'DELETE' })`. +- `AuditModule` помечен `@Global()` — доступен везде без явного импорта. +- `PrismaModule` тоже `@Global()`. + +--- + +## 4. Backend — модули + +``` +apps/api/src/modules/ +├── auth/ ← KeycloakStrategy + AuthGuard +├── employees/ ← основной CRUD + 7 sub-resources +│ ├── employees.{controller,service,module}.ts +│ ├── dto/{create,query}-employee.dto.ts +│ └── sub-resources/ +│ ├── identity-documents/ +│ ├── family-members/ +│ ├── educations/ +│ ├── qualifications/ +│ ├── trainings/ +│ ├── disciplinary-sanctions/ +│ ├── benefit/ +│ ├── contracts/ +│ └── sub-resources.service-factory.ts ← общая фабрика subCreate/subUpdate/subRemove +├── departments/ ← дерево отделов +├── reference/ ← read-only справочники, без аудита +├── evaluation/ ← Phase 4 — оценка персонала +│ ├── evaluation.{controller,service,module}.ts +│ ├── dto/{create-campaign,update-form,approve-form}.dto.ts +│ └── workers/evaluation-notifications.processor.ts ← BullMQ +├── medical/ ← Phase 5 — медицинский контроль +│ ├── medical.{controller,module}.ts +│ ├── dto/{risk-card,medical-profile,checkup}.dto.ts +│ └── services/ +│ ├── risk-cards.service.ts +│ ├── medical-profile.service.ts +│ ├── checkup.service.ts +│ ├── bulk.service.ts +│ ├── document-generator.service.ts ← рендер из TipTap-JSON через tiptap-to-docx +│ ├── tiptap-to-docx.ts ← конвертер TipTap doc → docx, поддержка repeatRows + variableChip +│ └── storage.service.ts ← MinIO client +├── admin/ +│ └── anexa-templates/ ← CRUD на AnexaTemplate (only hr_admin), drafts + версии +├── inventory/ ← Vestimentație / Echipament — InventoryItem CRUD + adjust-stock +├── contracts/ ← глобальный список CIM (отдельная страница) +├── notifications/ ← BullMQ daily-expiry cron (08:00 EE/Bucharest) → n8n webhook +│ └── workers/daily-expiry.processor.ts +└── dashboard/ ← агрегированная статистика +``` + +### Sub-resource pattern (employees) + +Все вложенные ресурсы (документы, семья, образование...) идут под: +``` +GET /employees/:employeeId/ +POST /employees/:employeeId/ +PATCH /employees/:employeeId//:id +DELETE /employees/:employeeId//:id +``` + +Сервисы используют общую фабрику `subCreate/subUpdate/subRemove` с проверкой принадлежности к employeeId. Все write-операции пишут в audit log. + +Роли по умолчанию: +- `GET` — все HR роли +- `POST/PATCH` — `hr_admin`, `hr_specialist` +- `DELETE` — только `hr_admin` + +### Особенности + +- **DisciplinarySanctions**: `dataExpirarii = dataAplicarii + 6 месяцев` вычисляется на сервере, не принимается от клиента. +- **Benefit**: используется `prisma.benefit.upsert()` — у сотрудника всегда максимум одна запись. +- **EmployeeMedicalProfile**: `dozaTotalaMsv` — computed поле (`externa + interna`) в response. + +--- + +## 5. Frontend — pages & роутинг + +``` +apps/web/src/ +├── App.tsx ← AppShell + NAV_ITEMS + Routes +├── main.tsx ← Mantine theme (medpark teal #008286, Montserrat) +├── api/ +│ ├── client.ts ← axios baseURL '/api/v1' + bearer token +│ └── types.ts ← все TS-интерфейсы синхронизированные с Prisma +├── pages/ +│ ├── auth/LoginPage.tsx +│ ├── dashboard/DashboardPage.tsx +│ ├── employees/ +│ │ ├── EmployeesPage.tsx ← список + поиск + фильтр +│ │ ├── EmployeeDetailPage.tsx ← header + 10 табов +│ │ ├── employeeSchema.ts ← Zod + IDNP checksum +│ │ ├── components/ +│ │ │ ├── EmployeeHeader.tsx +│ │ │ └── EmployeeDrawer.tsx ← создание / редактирование +│ │ ├── tabs/ +│ │ │ ├── PersonalTab.tsx +│ │ │ ├── DocumenteTab.tsx ← подсветка expirare < 30 дней (amber/red) +│ │ │ ├── FamilieTab.tsx +│ │ │ ├── StudiiTab.tsx +│ │ │ ├── CalificariTab.tsx +│ │ │ ├── TrainingTab.tsx +│ │ │ ├── SanctiuniTab.tsx ← активные (не stinsa) = красная строка +│ │ │ ├── BeneficiiTab.tsx +│ │ │ ├── ContracteTab.tsx +│ │ │ └── MedicalTab.tsx +│ │ └── drawers/ +│ │ └── *Drawer.tsx ← по одному на каждый sub-resource +│ ├── departments/DepartmentsPage.tsx ← дерево с раскрытием +│ ├── evaluation/ +│ │ ├── EvaluationPage.tsx ← список кампаний +│ │ ├── CampaignDetailPage.tsx ← список форм / категорий +│ │ ├── EvaluationFormPage.tsx ← заполнение блоков A-D +│ │ └── components/{ScoreInput,StatusBadge,CategoryBadge}.tsx +│ └── medical/ +│ ├── RiskCardsPage.tsx ← карты риска NU-10-MS-2026 +│ ├── MedicalControlPage.tsx ← bulk-select сотрудников + Generează documente +│ └── MedicalInboxPage.tsx ← inbox для medic_familie +└── styles/global.css ← CSS-переменные бренда +``` + +### Маршрутизация (фрагмент App.tsx) + +``` +/ → DashboardPage +/employees → EmployeesPage +/employees/:id → EmployeeDetailPage +/departments → DepartmentsPage +/evaluation → EvaluationPage +/evaluation/:id → CampaignDetailPage +/evaluation/form/:id → EvaluationFormPage +/risk-cards → RiskCardsPage +/medical → MedicalControlPage +/medic-inbox → MedicalInboxPage +``` + +> Маршруты `/admin/templates` и `/admin/templates/:type` удалены. Бэкенд-API `admin/anexa-templates` (CRUD + версии) сохранён. + +### State management + +- **TanStack Query** — fetch + кэш. Reference data: `staleTime: 300_000` (5 мин). +- Все табы сотрудника шарят один `useQuery(['employee', id])` — нет per-tab fetch'ей. +- Sub-resource записи делают `invalidateQueries(['employee', id])` → весь профиль рефетчится. +- Ключ кэша для медицины: `['medical-profile', employeeId]`, `['medical-checkups', employeeId]`, `['risk-cards']`. + +--- + +## 6. Бизнес-логика — критичные алгоритмы + +### IDNP (Moldovan ID number) — 13 цифр + +Реализовано **дважды**: на бэке (custom validator в DTO) и на фронте (Zod) для live-фидбека. + +```ts +function validateIdnp(idnp: string): boolean { + const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1]; + const sum = weights.reduce((acc, w, i) => acc + w * +idnp[i], 0); + return sum % 10 === +idnp[12]; +} +``` + +### Performance Evaluation — расчёт категории + +``` +function calculateCategory(form): + total = blockA + blockB + blockC + blockD + (testJci * 0.1) + if total >= 90 && expert_score >= threshold → 'superioara' + else if total >= 75 → 'cat_I' + else if total >= 50 → 'cat_II' + else → 'fara_categorie' +``` + +### Eligibility для оценки + +Сотрудник попадает в кампанию если стаж в компании на cutoff > 6 месяцев (по `dataAngajarii` контракта). + +### Anexa-документы (NU-10-MS-2026) + +Генерируются в `document-generator.service.ts` через `docx` библиотеку: + +| Документ | Когда генерируется | +|--------------------------|--------------------------------------------------------| +| Anexa 3 (Fișa solicitare) | при инициации контроля (bulk) | +| Anexa 4 (Fișa evaluare) | при инициации контроля (bulk) | +| Anexa 4B (Suplim. radiații) | если `expusRadiatiiIonizante = true` | +| Anexa 6 (Verdict) | после `medic_familie` ставит вердикт (`/checkups/:id/complete`) | + +DOCX сохраняются в MinIO (`s3://hrm-docs/`), URL пишется в `MedicalCheckup.documenteGenerate` (JSON массив `{name, url, type}`). + +### Скачивание / удаление документов + +- **Скачивание**: фронт извлекает key из `s3://bucket/key`, вызывает `GET /medical/documents/presign?key=...`, открывает presigned URL в новой вкладке. +- **Удаление одного**: `DELETE /medical/checkups/:id/documents?name=...` — удаляет из MinIO И из массива. +- **Удаление всех**: `DELETE /medical/checkups/:id/documents/all` — параллельно убирает все файлы из MinIO + чистит массив. + +### Контроль медицинский — типы + +``` +la_angajare ← перед трудоустройством +periodic ← плановый по карте риска +la_reluarea_activitatii ← после длительного отсутствия +la_incetarea_expunerii ← при увольнении из вредной среды +suplimentar ← по запросу +``` + +При `complete` для типов `la_angajare/periodic/la_reluarea_activitatii` обновляется `EmployeeMedicalProfile.dataUltimControlMedical` через `updateMany`. + +### Notifications (BullMQ) + +`evaluation-notifications.processor.ts` — `@Process('campaign-reminder')`. Постит в n8n webhook с массивом сотрудников. Cron-планирование на 14 дней до ожидаемого срока. + +--- + +## 7. Brand & UX + +### Цвета (Medpark) +- **Teal** `#008286` — primary, акценты, ссылки +- **Charcoal** `#58595b` — основной текст +- **Amber** `#fbb034` — предупреждения (умеренно) +- **Red** `#b11116` — деструктив, истёкшие даты, активные санкции + +### Шрифт +- **Montserrat**, weights 300/500/600/700 (импортируется в `index.html`) + +### Mantine theme + +```ts +const medparkTeal = ['#e6f4f4', ..., '#008286', ..., '#003d3f']; // 10 shades +createTheme({ + fontFamily: "'Montserrat', Arial, sans-serif", + primaryColor: 'medpark', + colors: { medpark: medparkTeal }, +}); +``` + +### Конвенции + +- Заголовки страниц: `` + teal underline (40×3 px, `borderRadius: 2`) +- Таблицы: `borderBottom: '2px solid teal'` на `<thead>`, `borderBottom: '1px solid #e9ecef'` на строках +- Кнопки primary: `background: teal`, `fontWeight: 500`, `height: 40` +- Drawers: `size="xl"`, секции с teal-left-border divider +- Badges по статусам: `medpark` (success), `red` (danger), `gray` (neutral), `orange` (warning) +- DateInput: `valueFormat="DD.MM.YYYY"`, на бэк отправляем `YYYY-MM-DD` (через `dayjs`) + +### Подсветка дат + +```ts +const days = dayjs(dataExpirarii).diff(dayjs(), 'day'); +if (days < 0) return 'red'; // истёкло +if (days < 30) return 'amber'; // истекает скоро +``` + +--- + +## 8. Интернационализация + +- Только румынский (`ro`). +- Backend: nestjs-i18n с файлом `apps/api/i18n/ro/translation.json`. +- Frontend: ключи захардкожены в компонентах (без i18next), либо в `apps/web/src/i18n/ro.json`. + +> Языковой переключатель в UI **отсутствует** — было удалено по требованию. + +--- + +## 9. Workflow разработки + +### Запуск + +```bash +# 1. Инфраструктура +docker compose up -d # postgres, redis, keycloak, minio + +# 2. Миграции (первый раз) +pnpm db:migrate + +# 3. Dev mode +pnpm dev # одновременно api (3001) + web (5173) +# или раздельно: +pnpm api:dev +pnpm web:dev +``` + +### Доступ + +- Frontend: http://localhost:5173 +- API: http://localhost:3001/api/v1 +- Prisma Studio: `pnpm db:studio` +- Keycloak admin: http://localhost:8080 +- MinIO console: http://localhost:9001 (minioadmin/minioadmin) + +### При изменении схемы + +```bash +# отредактировать apps/api/prisma/schema.prisma +pnpm --filter api prisma:migrate dev --name <migration_name> +# Prisma Client регенерится автоматически +``` + +### Линт / типчек + +```bash +pnpm --filter api typecheck +pnpm --filter web typecheck +``` + +--- + +## 10. Соглашения по коду + +### TypeScript +- `apps/web/src/api/types.ts` — единственный источник истины для интерфейсов на фронте; обновляется вручную при изменении Prisma-схемы. +- DTO бэкенда → используют `class-validator` декораторы. + +### Auth в контроллерах +```ts +@Controller('...') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class ... { + @Get(...) + @Roles('hr_admin', 'hr_specialist') + ...(@Request() req: AuthReq) { + return this.svc.method(..., req.user.id, req.user.role); + } +} +``` + +### Audit +Каждый write-метод в сервисе ОБЯЗАН вызвать `this.audit.logChange({ userId, userRole, action, entity, entityId })`. +Read-методы — `logRead({ userId, userRole, entity, entityId })` для чувствительных данных. + +### React Query mutations +```ts +const mutation = useMutation({ + mutationFn: (data) => apiClient.post(...), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['...'] }); + notifications.show({ color: 'medpark', title: '...', message: '...' }); + }, + onError: (err) => { + const msg = (err as any)?.response?.data?.message ?? 'Eroare'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + }, +}); +``` + +--- + +## 11. Roadmap & статус + +| Phase | Статус | Что входит | +|------------------------------|--------------|--------------------------------------------------------------| +| Phase 1: Employee master data | ✅ Done | CRUD + 8 sub-resources + drawer + детальная страница | +| Phase 2: Contracts | ⚠️ Частично | Schema + sub-resource controller, но нет специальной страницы | +| Phase 3: Departments | ✅ Done | Tree CRUD | +| Phase 4: Performance Evaluation | ✅ Done | Кампании, формы, утверждение, BullMQ напоминания | +| Phase 5: Medical Control | ✅ Done | Risk Cards, профили, checkups, bulk + DOCX, MinIO, inbox | +| Phase 5b: Anexa template editor | ✅ Done | TipTap v3 редактор шаблонов в `/admin/anexa-templates` (бэкенд + API), repeatRows, verdict.checkbox; **UI редактора удалён из фронтенда** | +| Phase 5c: Inventory | ✅ Done | InventoryItem CRUD, atomic stock $transaction в Benefit upsert | +| Phase 5d: Notifications | ✅ Done | BullMQ daily-expiry cron → n8n webhook (документы, calificări, contracte, medical, sancțiuni) | +| Phase 6: Polish | 🔲 Pending | Дашборды per-role, performance тесты, GDPR DPIA | + +### Pending hardening (TODO) + +- ✅ `POST /auth/dev-login` защищён `NODE_ENV === 'production'` guard (`ALLOW_DEV_LOGIN=true` для тестового продакшена) +- ✅ BullMQ daily cron → n8n webhook (docs/categories/contracts/medical expiry) — `notifications` модуль +- 🔲 Excel HR import (`POST /employees/import` через `exceljs`) — НЕ требование, отложено +- 🔲 Seed data для DisabilityGrade / TaxExemption (department seed готов) +- 🔲 Dev-mode Keycloak bypass (сейчас все API возвращают 401 без валидного JWT) + +--- + +## 12. Где искать что + +| Задача | Файл / директория | +|-------------------------------------|------------------------------------------------------------------| +| Поменять схему БД | `apps/api/prisma/schema.prisma` | +| Добавить эндпоинт | `apps/api/src/modules/<module>/*.controller.ts` | +| Изменить роли доступа | `@Roles(...)` в контроллере | +| Изменить тему / цвета | `apps/web/src/main.tsx` + `apps/web/src/styles/global.css` | +| Поменять навигацию | `apps/web/src/App.tsx` → `NAV_ITEMS` | +| Добавить TypeScript-тип | `apps/web/src/api/types.ts` | +| Добавить sub-resource сотрудника | `apps/api/src/modules/employees/sub-resources/<name>/` | +| Изменить DOCX-документ | `apps/api/src/modules/medical/services/document-generator.service.ts` (рендер) + `tiptap-to-docx.ts` (конвертер) + `/admin/anexa-templates` UI (контент) | +| Поправить cron-нотификации | `apps/api/src/modules/notifications/notifications.service.ts` (правила expiry) + `notifications.module.ts` (cron-расписание) | +| Управление складом | `apps/api/src/modules/inventory/` + `apps/web/src/pages/inventory/` | +| Аудит-логи | таблица `AuditLog`, доступ через `AuditService` | +| Reference-справочники | `apps/api/src/modules/reference/reference.controller.ts` | + +--- + +## 13. Полезные команды + +```bash +# Открыть Prisma Studio (визуальный БД-инспектор) +pnpm db:studio + +# Создать миграцию после изменения schema.prisma +pnpm --filter api exec prisma migrate dev --name <name> + +# Перегенерировать Prisma Client +pnpm --filter api exec prisma generate + +# Type check всего монорепо +pnpm -r typecheck + +# Сбросить БД (только в dev!) +pnpm --filter api exec prisma migrate reset +``` diff --git a/NU-10-MS-2026_2.docx b/NU-10-MS-2026_2.docx new file mode 100644 index 0000000..8f382ea Binary files /dev/null and b/NU-10-MS-2026_2.docx differ diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..daf01c3 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# Build context = monorepo root (hrm-medpark/) + +FROM node:20-bookworm-slim AS base +ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" +RUN corepack enable && apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +WORKDIR /repo + +# ---- install + build ---- +FROM base AS build +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +RUN pnpm install --frozen-lockfile +COPY apps/api apps/api +RUN pnpm --filter api exec prisma generate && pnpm --filter api build + +# ---- runtime ---- +FROM base AS runtime +ENV NODE_ENV=production +WORKDIR /repo +# pnpm keeps the package store at the repo-root node_modules/.pnpm; copy both trees so symlinks resolve +COPY --from=build /repo/node_modules ./node_modules +COPY --from=build /repo/apps/api ./apps/api +WORKDIR /repo/apps/api +EXPOSE 3001 +# apply pending migrations, then start; falls back to start if migrate has nothing to do +CMD ["sh", "-c", "pnpm exec prisma migrate deploy && node dist/main"] diff --git a/apps/api/i18n/en/translation.json b/apps/api/i18n/en/translation.json new file mode 100644 index 0000000..be45451 --- /dev/null +++ b/apps/api/i18n/en/translation.json @@ -0,0 +1,15 @@ +{ + "errors": { + "not_found": "Resource with id={id} not found", + "conflict_idnp": "IDNP {idnp} already exists in the system", + "idnp_invalid": "Invalid IDNP (13 digits, wrong check digit)", + "recomandare_sot": "Cannot select the employee's spouse as an internal recommendation", + "unauthorized": "Unauthorized", + "forbidden": "Insufficient permissions" + }, + "validation": { + "required": "Required field", + "email": "Invalid email address", + "phone": "Invalid phone number" + } +} diff --git a/apps/api/i18n/ro/translation.json b/apps/api/i18n/ro/translation.json new file mode 100644 index 0000000..4e79392 --- /dev/null +++ b/apps/api/i18n/ro/translation.json @@ -0,0 +1,15 @@ +{ + "errors": { + "not_found": "Resursa cu id={id} nu a fost găsită", + "conflict_idnp": "IDNP {idnp} există deja în sistem", + "idnp_invalid": "IDNP invalid (13 cifre, cifra de control incorectă)", + "recomandare_sot": "Nu se poate selecta soțul/soția angajatului ca recomandare internă", + "unauthorized": "Acces neautorizat", + "forbidden": "Nu aveți permisiuni suficiente" + }, + "validation": { + "required": "Câmp obligatoriu", + "email": "Adresă email invalidă", + "phone": "Număr de telefon invalid" + } +} diff --git a/apps/api/i18n/ru/translation.json b/apps/api/i18n/ru/translation.json new file mode 100644 index 0000000..9f4b9db --- /dev/null +++ b/apps/api/i18n/ru/translation.json @@ -0,0 +1,15 @@ +{ + "errors": { + "not_found": "Ресурс с id={id} не найден", + "conflict_idnp": "IDNP {idnp} уже существует в системе", + "idnp_invalid": "Некорректный IDNP (13 цифр, неверная контрольная цифра)", + "recomandare_sot": "Нельзя выбрать супруга сотрудника в качестве внутренней рекомендации", + "unauthorized": "Не авторизован", + "forbidden": "Недостаточно прав" + }, + "validation": { + "required": "Обязательное поле", + "email": "Некорректный email", + "phone": "Некорректный номер телефона" + } +} diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..b757b8c --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,57 @@ +{ + "name": "api", + "version": "0.1.0", + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "prisma:migrate": "prisma migrate dev", + "prisma:generate": "prisma generate", + "prisma:studio": "prisma studio", + "prisma:seed": "ts-node prisma/seed.ts", + "docx:stubs": "ts-node scripts/generate-docx-stubs.ts", + "testdb:run": "ts-node scripts/test-db.ts run", + "testdb:verify": "ts-node scripts/verify-functionality.ts", + "testdb:drop": "ts-node scripts/test-db.ts drop" + }, + "dependencies": { + "@nestjs/axios": "^3.0.2", + "@nestjs/bull": "^10.1.1", + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^10.3.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-fastify": "^10.3.0", + "@nestjs/throttler": "^5.1.1", + "@prisma/client": "^5.11.0", + "argon2": "^0.40.1", + "axios": "^1.6.8", + "bull": "^4.12.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "docx": "^9.6.1", + "docxtemplater": "^3.47.0", + "exceljs": "^4.4.0", + "jwks-rsa": "^3.2.2", + "minio": "^8.0.0", + "nestjs-i18n": "^10.4.5", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pizzip": "^3.1.4", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.2", + "@nestjs/schematics": "^10.1.1", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "prisma": "^5.11.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.2" + } +} diff --git a/apps/api/prisma/migrations/20260429071206_/migration.sql b/apps/api/prisma/migrations/20260429071206_/migration.sql new file mode 100644 index 0000000..5254c0f --- /dev/null +++ b/apps/api/prisma/migrations/20260429071206_/migration.sql @@ -0,0 +1,578 @@ +-- CreateEnum +CREATE TYPE "Sex" AS ENUM ('F', 'M'); + +-- CreateEnum +CREATE TYPE "MaritalStatus" AS ENUM ('casatorit', 'necasatorit', 'divortat', 'vaduv'); + +-- CreateEnum +CREATE TYPE "EmployeeStatus" AS ENUM ('activ', 'concediat', 'suspendat'); + +-- CreateEnum +CREATE TYPE "DocumentType" AS ENUM ('buletin_de_identitate', 'pasaport'); + +-- CreateEnum +CREATE TYPE "FamilyMemberType" AS ENUM ('contact_principal', 'sot', 'sotie', 'mama', 'tata', 'copil'); + +-- CreateEnum +CREATE TYPE "StudyType" AS ENUM ('superioare', 'medii_de_specialitate', 'secundare_tehnice', 'medii'); + +-- CreateEnum +CREATE TYPE "StudyLevel" AS ENUM ('de_baza', 'postuniversitar'); + +-- CreateEnum +CREATE TYPE "PostUniversityType" AS ENUM ('masterat', 'rezidentiat', 'secundariat', 'altele'); + +-- CreateEnum +CREATE TYPE "DiplomaStatus" AS ENUM ('confirmata', 'neconfirmata'); + +-- CreateEnum +CREATE TYPE "QualificationCategory" AS ENUM ('fara', 'cat_II', 'cat_I', 'superioara'); + +-- CreateEnum +CREATE TYPE "ScientificTitle" AS ENUM ('doctor', 'doctor_habilitat'); + +-- CreateEnum +CREATE TYPE "TrainingType" AS ENUM ('orientare', 'intern', 'extern_RM', 'extern_international'); + +-- CreateEnum +CREATE TYPE "DisciplinarySanctionType" AS ENUM ('avertisment', 'mustrare', 'mustrare_aspra'); + +-- CreateEnum +CREATE TYPE "ContractPeriod" AS ENUM ('determinata', 'nedeterminata', 'replasare_temporara'); + +-- CreateEnum +CREATE TYPE "ContractCategory" AS ENUM ('principal', 'secundar'); + +-- CreateEnum +CREATE TYPE "ContractType" AS ENUM ('de_baza', 'cumul'); + +-- CreateEnum +CREATE TYPE "SalaryType" AS ENUM ('fix', 'pe_ore', 'in_acord'); + +-- CreateEnum +CREATE TYPE "CampaignStatus" AS ENUM ('draft', 'scheduled', 'in_progress', 'closed'); + +-- CreateEnum +CREATE TYPE "EvaluationScore" AS ENUM ('slab', 'mediu', 'bine'); + +-- CreateEnum +CREATE TYPE "ProposedCategory" AS ENUM ('fara', 'cat_II', 'cat_I', 'superioara'); + +-- CreateEnum +CREATE TYPE "MedicalCheckupType" AS ENUM ('la_angajare', 'periodic', 'la_reluarea_activitatii', 'la_incetarea_expunerii', 'suplimentar'); + +-- CreateEnum +CREATE TYPE "MedicalVerdict" AS ENUM ('apt', 'apt_perioada_adaptare', 'apt_conditionat', 'inapt_temporar', 'inapt'); + +-- CreateTable +CREATE TABLE "disability_grades" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "disability_grades_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tax_exemptions" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "description" TEXT NOT NULL, + + CONSTRAINT "tax_exemptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_schedules" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "daysWork" INTEGER NOT NULL, + "daysRest" INTEGER NOT NULL, + "hoursPerDay" INTEGER NOT NULL, + + CONSTRAINT "work_schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "departments" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "parentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "departments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "employees" ( + "id" TEXT NOT NULL, + "idnp" VARCHAR(13) NOT NULL, + "nume" TEXT NOT NULL, + "prenume" TEXT NOT NULL, + "patronimic" TEXT, + "numeAnterior" TEXT, + "dataNasterii" DATE NOT NULL, + "domiciliu" TEXT NOT NULL, + "adresaReala" TEXT, + "telefonPersonal" TEXT NOT NULL, + "telefonServiciu" TEXT, + "emailPersonal" TEXT, + "emailCorporativ" TEXT, + "sex" "Sex" NOT NULL, + "codCpas" TEXT, + "stareCivila" "MaritalStatus", + "titluStiintific" "ScientificTitle", + "titluUniversitar" TEXT, + "status" "EmployeeStatus" NOT NULL DEFAULT 'activ', + "gradDizabilitateId" TEXT, + "recomandareInternaId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "employees_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "identity_documents" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "tipAct" "DocumentType" NOT NULL, + "seria" TEXT, + "nr" TEXT NOT NULL, + "dataEmiterii" DATE NOT NULL, + "autoritateEmitenta" TEXT NOT NULL, + "dataExpirarii" DATE NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "identity_documents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "family_members" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "tip" "FamilyMemberType" NOT NULL, + "numePrenume" TEXT NOT NULL, + "dataNasterii" DATE, + "idnp" VARCHAR(13), + "telefon" TEXT, + "tipScutireId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "family_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "educations" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "tipStudii" "StudyType" NOT NULL, + "institutia" TEXT NOT NULL, + "specialitatea" TEXT NOT NULL, + "dataAbsolvirii" DATE, + "nrSeriaDiploma" TEXT, + "dataEmiterii" DATE, + "nrInregistrare" TEXT, + "confirmare" "DiplomaStatus", + "nivel" "StudyLevel", + "tipPostuniversitar" "PostUniversityType", + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "educations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "qualifications" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "categorie" "QualificationCategory" NOT NULL, + "dataObtinerii" DATE, + "dataUltimeiConfirmari" DATE, + "dataExpirarii" DATE, + "specialitate" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "qualifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "trainings" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "denumire" TEXT NOT NULL, + "inceput" DATE NOT NULL, + "sfirsit" DATE, + "tip" "TrainingType" NOT NULL, + "tara" TEXT, + "nrOre" INTEGER, + "organizatia" TEXT, + "certificat" BOOLEAN NOT NULL DEFAULT false, + "cost" DECIMAL(10,2), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "trainings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "disciplinary_sanctions" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "tip" "DisciplinarySanctionType" NOT NULL, + "dataAplicarii" DATE NOT NULL, + "dataExpirarii" DATE NOT NULL, + "isStinsa" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "disciplinary_sanctions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "benefits" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "uniformaId" TEXT, + "halatId" TEXT, + "ciupiciId" TEXT, + "vestaId" TEXT, + "ticheteMasa" BOOLEAN NOT NULL DEFAULT false, + "valoareTichet" DECIMAL(10,2), + "alimentatiePersonal" BOOLEAN NOT NULL DEFAULT false, + "abonamentTel" DECIMAL(10,2), + "aparatTelefonId" TEXT, + "cardCompanie" TEXT, + "automobilServiciu" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "benefits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "employment_contracts" ( + "id" TEXT NOT NULL, + "nrCim" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "categorie" "ContractCategory" NOT NULL, + "dataSemnarii" DATE NOT NULL, + "dataAngajarii" DATE NOT NULL, + "dataDemisiei" DATE, + "perioada" "ContractPeriod" NOT NULL, + "dataTerminarii" DATE, + "functiaClasificator" TEXT, + "codFunctie" TEXT, + "functiaOrganigrama" TEXT, + "tipCim" "ContractType" NOT NULL, + "departmentId" TEXT NOT NULL, + "regimMunca" TEXT, + "tipSalarizare" "SalaryType", + "salarizareDetails" JSONB, + "clausaAditionala" JSONB, + "workScheduleId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "employment_contracts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cim_service_categories" ( + "id" TEXT NOT NULL, + "contractId" TEXT NOT NULL, + "categorieId" TEXT NOT NULL, + "tipRemunerare" TEXT NOT NULL, + "sumaNeta" DECIMAL(10,2), + "procent" DECIMAL(5,2), + + CONSTRAINT "cim_service_categories_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "evaluation_campaigns" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "departmentId" TEXT NOT NULL, + "month" DATE NOT NULL, + "status" "CampaignStatus" NOT NULL DEFAULT 'draft', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "evaluation_campaigns_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "evaluation_forms" ( + "id" TEXT NOT NULL, + "campaignId" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "abilitatiClinice" "EvaluationScore", + "judecataClinica" "EvaluationScore", + "manopere" "EvaluationScore", + "gestionareaSarcinilor" "EvaluationScore", + "constiintaProfesionala" "EvaluationScore", + "atitudineaPacienti" "EvaluationScore", + "atitudineaColegi" "EvaluationScore", + "atitudineaPersonalNonMed" "EvaluationScore", + "utilizareSmartphone" "EvaluationScore", + "respectareaProgramului" "EvaluationScore", + "respectareaDressCode" "EvaluationScore", + "testJci" JSONB, + "completareaDocMed" BOOLEAN, + "perfectioneazaCunostinte" BOOLEAN, + "membruComitetCalitate" BOOLEAN, + "functieDeMonitor" BOOLEAN, + "inlocuiesteSuperiorul" BOOLEAN, + "categorieCalculata" "ProposedCategory", + "categorieAprobata" "ProposedCategory", + "observatii" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "evaluation_forms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workplace_risk_cards" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "riskFactors" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workplace_risk_cards_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "employee_medical_profiles" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "ocupatieCorm" TEXT, + "workplaceRiskCardId" TEXT, + "dataUltimControlMedical" DATE, + "expusRadiatiiIonizante" BOOLEAN NOT NULL DEFAULT false, + "dataIntrarii" DATE, + "expunereAnterioaraPerioda" TEXT, + "expunereAnterioaraAni" INTEGER, + "dozaCumulataExternaMsv" DECIMAL(10,4), + "dozaCumulataInternaMsv" DECIMAL(10,4), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "employee_medical_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medical_checkups" ( + "id" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "tip" "MedicalCheckupType" NOT NULL, + "dataPlanificata" DATE NOT NULL, + "dataEfectuata" DATE, + "verdict" "MedicalVerdict", + "recomandari" TEXT, + "documenteGenerate" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "medical_checkups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" BIGSERIAL NOT NULL, + "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "userRole" VARCHAR(50) NOT NULL, + "ip" VARCHAR(45), + "action" VARCHAR(20) NOT NULL, + "entity" VARCHAR(50) NOT NULL, + "entityId" VARCHAR(50) NOT NULL, + "field" VARCHAR(100), + "oldValue" TEXT, + "newValue" TEXT, + "reason" TEXT, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "disability_grades_code_key" ON "disability_grades"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "tax_exemptions_code_key" ON "tax_exemptions"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_schedules_name_key" ON "work_schedules"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "departments_code_key" ON "departments"("code"); + +-- CreateIndex +CREATE INDEX "departments_parentId_idx" ON "departments"("parentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "employees_idnp_key" ON "employees"("idnp"); + +-- CreateIndex +CREATE INDEX "employees_idnp_idx" ON "employees"("idnp"); + +-- CreateIndex +CREATE INDEX "employees_nume_prenume_idx" ON "employees"("nume", "prenume"); + +-- CreateIndex +CREATE INDEX "employees_status_idx" ON "employees"("status"); + +-- CreateIndex +CREATE INDEX "employees_dataNasterii_idx" ON "employees"("dataNasterii"); + +-- CreateIndex +CREATE INDEX "identity_documents_employeeId_idx" ON "identity_documents"("employeeId"); + +-- CreateIndex +CREATE INDEX "identity_documents_dataExpirarii_idx" ON "identity_documents"("dataExpirarii"); + +-- CreateIndex +CREATE INDEX "family_members_employeeId_idx" ON "family_members"("employeeId"); + +-- CreateIndex +CREATE INDEX "educations_employeeId_idx" ON "educations"("employeeId"); + +-- CreateIndex +CREATE INDEX "qualifications_employeeId_idx" ON "qualifications"("employeeId"); + +-- CreateIndex +CREATE INDEX "qualifications_dataExpirarii_idx" ON "qualifications"("dataExpirarii"); + +-- CreateIndex +CREATE INDEX "trainings_employeeId_idx" ON "trainings"("employeeId"); + +-- CreateIndex +CREATE INDEX "disciplinary_sanctions_employeeId_idx" ON "disciplinary_sanctions"("employeeId"); + +-- CreateIndex +CREATE INDEX "disciplinary_sanctions_dataExpirarii_idx" ON "disciplinary_sanctions"("dataExpirarii"); + +-- CreateIndex +CREATE UNIQUE INDEX "benefits_employeeId_key" ON "benefits"("employeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "employment_contracts_nrCim_key" ON "employment_contracts"("nrCim"); + +-- CreateIndex +CREATE INDEX "employment_contracts_employeeId_idx" ON "employment_contracts"("employeeId"); + +-- CreateIndex +CREATE INDEX "employment_contracts_departmentId_idx" ON "employment_contracts"("departmentId"); + +-- CreateIndex +CREATE INDEX "employment_contracts_dataDemisiei_idx" ON "employment_contracts"("dataDemisiei"); + +-- CreateIndex +CREATE INDEX "cim_service_categories_contractId_idx" ON "cim_service_categories"("contractId"); + +-- CreateIndex +CREATE INDEX "evaluation_campaigns_departmentId_idx" ON "evaluation_campaigns"("departmentId"); + +-- CreateIndex +CREATE INDEX "evaluation_campaigns_month_idx" ON "evaluation_campaigns"("month"); + +-- CreateIndex +CREATE INDEX "evaluation_forms_campaignId_idx" ON "evaluation_forms"("campaignId"); + +-- CreateIndex +CREATE INDEX "evaluation_forms_employeeId_idx" ON "evaluation_forms"("employeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "evaluation_forms_campaignId_employeeId_key" ON "evaluation_forms"("campaignId", "employeeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "workplace_risk_cards_name_key" ON "workplace_risk_cards"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "employee_medical_profiles_employeeId_key" ON "employee_medical_profiles"("employeeId"); + +-- CreateIndex +CREATE INDEX "medical_checkups_employeeId_idx" ON "medical_checkups"("employeeId"); + +-- CreateIndex +CREATE INDEX "medical_checkups_dataPlanificata_idx" ON "medical_checkups"("dataPlanificata"); + +-- CreateIndex +CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId"); + +-- CreateIndex +CREATE INDEX "audit_logs_entity_entityId_idx" ON "audit_logs"("entity", "entityId"); + +-- CreateIndex +CREATE INDEX "audit_logs_ts_idx" ON "audit_logs"("ts"); + +-- AddForeignKey +ALTER TABLE "departments" ADD CONSTRAINT "departments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employees" ADD CONSTRAINT "employees_gradDizabilitateId_fkey" FOREIGN KEY ("gradDizabilitateId") REFERENCES "disability_grades"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employees" ADD CONSTRAINT "employees_recomandareInternaId_fkey" FOREIGN KEY ("recomandareInternaId") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "identity_documents" ADD CONSTRAINT "identity_documents_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "family_members" ADD CONSTRAINT "family_members_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "family_members" ADD CONSTRAINT "family_members_tipScutireId_fkey" FOREIGN KEY ("tipScutireId") REFERENCES "tax_exemptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "educations" ADD CONSTRAINT "educations_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "qualifications" ADD CONSTRAINT "qualifications_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "trainings" ADD CONSTRAINT "trainings_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "disciplinary_sanctions" ADD CONSTRAINT "disciplinary_sanctions_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employment_contracts" ADD CONSTRAINT "employment_contracts_workScheduleId_fkey" FOREIGN KEY ("workScheduleId") REFERENCES "work_schedules"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cim_service_categories" ADD CONSTRAINT "cim_service_categories_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "employment_contracts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "evaluation_campaigns" ADD CONSTRAINT "evaluation_campaigns_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "evaluation_forms" ADD CONSTRAINT "evaluation_forms_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "evaluation_campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "evaluation_forms" ADD CONSTRAINT "evaluation_forms_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employee_medical_profiles" ADD CONSTRAINT "employee_medical_profiles_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "employee_medical_profiles" ADD CONSTRAINT "employee_medical_profiles_workplaceRiskCardId_fkey" FOREIGN KEY ("workplaceRiskCardId") REFERENCES "workplace_risk_cards"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medical_checkups" ADD CONSTRAINT "medical_checkups_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260508120000_add_anexa_templates/migration.sql b/apps/api/prisma/migrations/20260508120000_add_anexa_templates/migration.sql new file mode 100644 index 0000000..393e85e --- /dev/null +++ b/apps/api/prisma/migrations/20260508120000_add_anexa_templates/migration.sql @@ -0,0 +1,35 @@ +-- CreateEnum +CREATE TYPE "AnexaType" AS ENUM ('ANEXA_3', 'ANEXA_4', 'ANEXA_4B', 'ANEXA_6'); + +-- CreateTable +CREATE TABLE "anexa_templates" ( + "id" TEXT NOT NULL, + "type" "AnexaType" NOT NULL, + "name" TEXT NOT NULL, + "contentJson" JSONB NOT NULL, + "updatedById" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "anexa_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "anexa_template_versions" ( + "id" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "contentJson" JSONB NOT NULL, + "savedById" TEXT NOT NULL, + "savedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "label" TEXT, + + CONSTRAINT "anexa_template_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "anexa_templates_type_key" ON "anexa_templates"("type"); + +-- CreateIndex +CREATE INDEX "anexa_template_versions_templateId_idx" ON "anexa_template_versions"("templateId"); + +-- AddForeignKey +ALTER TABLE "anexa_template_versions" ADD CONSTRAINT "anexa_template_versions_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "anexa_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql b/apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql new file mode 100644 index 0000000..4a439f5 --- /dev/null +++ b/apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql @@ -0,0 +1,47 @@ +-- CreateEnum +CREATE TYPE "InventoryItemType" AS ENUM ('uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte'); + +-- CreateTable +CREATE TABLE "inventory_items" ( + "id" TEXT NOT NULL, + "sku" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "InventoryItemType" NOT NULL, + "size" TEXT, + "color" TEXT, + "pricePerUnit" DECIMAL(10,2), + "stockQty" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku"); + +-- CreateIndex +CREATE INDEX "inventory_items_type_active_idx" ON "inventory_items"("type", "active"); + +-- Reset existing free-text IDs in benefits (no FK referenced anything real before) +UPDATE "benefits" SET "uniformaId" = NULL WHERE "uniformaId" IS NOT NULL; +UPDATE "benefits" SET "halatId" = NULL WHERE "halatId" IS NOT NULL; +UPDATE "benefits" SET "ciupiciId" = NULL WHERE "ciupiciId" IS NOT NULL; +UPDATE "benefits" SET "vestaId" = NULL WHERE "vestaId" IS NOT NULL; +UPDATE "benefits" SET "aparatTelefonId" = NULL WHERE "aparatTelefonId" IS NOT NULL; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_uniformaId_fkey" FOREIGN KEY ("uniformaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_halatId_fkey" FOREIGN KEY ("halatId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_ciupiciId_fkey" FOREIGN KEY ("ciupiciId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_vestaId_fkey" FOREIGN KEY ("vestaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "benefits" ADD CONSTRAINT "benefits_aparatTelefonId_fkey" FOREIGN KEY ("aparatTelefonId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260525120000_add_anexa4_risk_card_fields/migration.sql b/apps/api/prisma/migrations/20260525120000_add_anexa4_risk_card_fields/migration.sql new file mode 100644 index 0000000..d9b98c8 --- /dev/null +++ b/apps/api/prisma/migrations/20260525120000_add_anexa4_risk_card_fields/migration.sql @@ -0,0 +1,52 @@ +-- CreateEnum +CREATE TYPE "RiskExposureType" AS ENUM ('AGENT_CHIMIC', 'PULBERI', 'AGENT_BIOLOGIC', 'ZGOMOT', 'VIBRATII', 'CAMP_ELECTROMAGNETIC', 'RADIATII_OPTICE'); + +-- AlterTable +ALTER TABLE "workplace_risk_cards" ADD COLUMN "adresaFiliala" TEXT, +ADD COLUMN "anexeIgienicoSanitare" JSONB, +ADD COLUMN "caemDiviziune" TEXT, +ADD COLUMN "caemPrimeleDouaCifre" TEXT, +ADD COLUMN "clasaConditiilorDeMunca" TEXT, +ADD COLUMN "cormSubgrupaMajora" TEXT, +ADD COLUMN "directiaSectiaSectorul" TEXT, +ADD COLUMN "echipamentLucru" TEXT, +ADD COLUMN "evaluareDetalii" JSONB, +ADD COLUMN "filiala" TEXT, +ADD COLUMN "mijloaceProtectieColectiva" TEXT, +ADD COLUMN "mijloaceProtectieIndividuala" TEXT, +ADD COLUMN "numarLucratoriPosibili" INTEGER, +ADD COLUMN "numarulLoculuiDeMunca" TEXT, +ADD COLUMN "observatii" TEXT, +ADD COLUMN "radiatiiAparatura" TEXT, +ADD COLUMN "radiatiiGrupa" TEXT, +ADD COLUMN "radiatiiIonizante" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "radiatiiMasuriProtectie" TEXT, +ADD COLUMN "radiatiiSurse" TEXT, +ADD COLUMN "radiatiiTipExpunere" TEXT, +ALTER COLUMN "riskFactors" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "workplace_risk_exposures" ( + "id" TEXT NOT NULL, + "cardId" TEXT NOT NULL, + "tip" "RiskExposureType" NOT NULL, + "denumire" TEXT NOT NULL, + "cas" TEXT, + "einecs" TEXT, + "clasificare" TEXT, + "zonaAfectata" TEXT, + "timpExpunere" TEXT, + "vep" TEXT, + "vlep" TEXT, + "caracteristici" TEXT, + "procesVerbal" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workplace_risk_exposures_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "workplace_risk_exposures_cardId_idx" ON "workplace_risk_exposures"("cardId"); + +-- AddForeignKey +ALTER TABLE "workplace_risk_exposures" ADD CONSTRAINT "workplace_risk_exposures_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "workplace_risk_cards"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260526120000_add_overexposure_tipfisa_anexa4a/migration.sql b/apps/api/prisma/migrations/20260526120000_add_overexposure_tipfisa_anexa4a/migration.sql new file mode 100644 index 0000000..9059067 --- /dev/null +++ b/apps/api/prisma/migrations/20260526120000_add_overexposure_tipfisa_anexa4a/migration.sql @@ -0,0 +1,27 @@ +-- AlterEnum +ALTER TYPE "AnexaType" ADD VALUE 'ANEXA_4A'; + +-- CreateEnum +CREATE TYPE "OverexposureKind" AS ENUM ('EXCEPTIONALA', 'ACCIDENTALA'); + +-- AlterTable +ALTER TABLE "workplace_risk_cards" ADD COLUMN "tipFisa" TEXT NOT NULL DEFAULT 'STANDARD'; + +-- CreateTable +CREATE TABLE "radiation_overexposures" ( + "id" TEXT NOT NULL, + "medicalProfileId" TEXT NOT NULL, + "fel" "OverexposureKind" NOT NULL, + "tipExpunere" TEXT, + "data" DATE, + "dozaMsv" DECIMAL(10,4), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "radiation_overexposures_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "radiation_overexposures_medicalProfileId_idx" ON "radiation_overexposures"("medicalProfileId"); + +-- AddForeignKey +ALTER TABLE "radiation_overexposures" ADD CONSTRAINT "radiation_overexposures_medicalProfileId_fkey" FOREIGN KEY ("medicalProfileId") REFERENCES "employee_medical_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260527153000_docx_template_fill_fields/migration.sql b/apps/api/prisma/migrations/20260527153000_docx_template_fill_fields/migration.sql new file mode 100644 index 0000000..3d1f577 --- /dev/null +++ b/apps/api/prisma/migrations/20260527153000_docx_template_fill_fields/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "workplace_risk_cards" +ADD COLUMN "telefonFiliala" TEXT; + +ALTER TABLE "medical_checkups" +ADD COLUMN "valabilPanaLa" DATE, +ADD COLUMN "semnatDe" TEXT; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..3d703e6 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,829 @@ +// HRM Medpark — Prisma Schema +// Phase 1: Employee Master Data + Department + AuditLog +// Phase 2 stubs: EmploymentContract +// Phase 4 stubs: EvaluationCampaign, EvaluationForm +// Phase 5 stubs: WorkplaceRiskCard, EmployeeMedicalProfile, MedicalCheckup + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ═══════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════ + +enum Sex { + F + M +} + +enum MaritalStatus { + casatorit + necasatorit + divortat + vaduv +} + +enum EmployeeStatus { + activ + concediat + suspendat +} + +enum DocumentType { + buletin_de_identitate + pasaport +} + +enum FamilyMemberType { + contact_principal + sot + sotie + mama + tata + copil +} + +enum StudyType { + superioare + medii_de_specialitate + secundare_tehnice + medii +} + +enum StudyLevel { + de_baza + postuniversitar +} + +enum PostUniversityType { + masterat + rezidentiat + secundariat + altele +} + +enum DiplomaStatus { + confirmata + neconfirmata +} + +enum QualificationCategory { + fara + cat_II + cat_I + superioara +} + +enum ScientificTitle { + doctor + doctor_habilitat +} + +enum TrainingType { + orientare + intern + extern_RM + extern_international +} + +enum DisciplinarySanctionType { + avertisment + mustrare + mustrare_aspra +} + +// Phase 2 +enum ContractPeriod { + determinata + nedeterminata + replasare_temporara +} + +enum ContractCategory { + principal + secundar +} + +enum ContractType { + de_baza + cumul +} + +enum SalaryType { + fix + pe_ore + in_acord +} + +// Phase 4 +enum CampaignStatus { + draft + scheduled + in_progress + closed +} + +enum EvaluationScore { + slab + mediu + bine +} + +enum ProposedCategory { + fara + cat_II + cat_I + superioara +} + +// Phase 5 +enum MedicalCheckupType { + la_angajare + periodic + la_reluarea_activitatii + la_incetarea_expunerii + suplimentar +} + +enum MedicalVerdict { + apt + apt_perioada_adaptare + apt_conditionat + inapt_temporar + inapt +} + +enum AnexaType { + ANEXA_3 + ANEXA_4 + ANEXA_4A + ANEXA_4B + ANEXA_6 +} + +// Tipuri de factori cu tabel de expunere în Anexa 4 (NU-10-MS-2026) +enum RiskExposureType { + AGENT_CHIMIC + PULBERI + AGENT_BIOLOGIC + ZGOMOT + VIBRATII + CAMP_ELECTROMAGNETIC + RADIATII_OPTICE +} + +// Tipul supraexpunerii la radiații ionizante (Anexa 4B) +enum OverexposureKind { + EXCEPTIONALA + ACCIDENTALA +} + +// ═══════════════════════════════════════════════════════════════ +// СПРАВОЧНИКИ +// ═══════════════════════════════════════════════════════════════ + +model DisabilityGrade { + id String @id @default(uuid()) + code String @unique + name String + employees Employee[] + + @@map("disability_grades") +} + +model TaxExemption { + id String @id @default(uuid()) + code String @unique + description String + familyMembers FamilyMember[] + + @@map("tax_exemptions") +} + +model WorkSchedule { + id String @id @default(uuid()) + name String @unique // "5/2 8h", "7/7 12h" + daysWork Int + daysRest Int + hoursPerDay Int + contracts EmploymentContract[] + + @@map("work_schedules") +} + +// ═══════════════════════════════════════════════════════════════ +// DEPARTMENT — иерархия (adjacency list) +// ═══════════════════════════════════════════════════════════════ + +model Department { + id String @id @default(uuid()) + name String + code String? @unique + parentId String? + parent Department? @relation("DeptTree", fields: [parentId], references: [id]) + children Department[] @relation("DeptTree") + + contracts EmploymentContract[] + campaigns EvaluationCampaign[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([parentId]) + @@map("departments") +} + +// ═══════════════════════════════════════════════════════════════ +// EMPLOYEE — ядро системы +// ═══════════════════════════════════════════════════════════════ + +model Employee { + id String @id @default(uuid()) + // IDNP — 13 цифр, алгоритм контрольной суммы валидируется на app-уровне + idnp String @unique @db.VarChar(13) + + // A. Личная информация + nume String + prenume String + patronimic String? + numeAnterior String? + dataNasterii DateTime @db.Date + domiciliu String + adresaReala String? + telefonPersonal String + telefonServiciu String? + emailPersonal String? + emailCorporativ String? + sex Sex + codCpas String? + stareCivila MaritalStatus? + + // Научное/университетское звание (уровень Employee, не Qualification) + titluStiintific ScientificTitle? + titluUniversitar String? + + status EmployeeStatus @default(activ) + + gradDizabilitateId String? + gradDizabilitate DisabilityGrade? @relation(fields: [gradDizabilitateId], references: [id]) + + // Кто рекомендовал (самоссылка) + // Бизнес-правило: нельзя выбрать супруга текущего сотрудника — проверка на service-уровне + recomandareInternaId String? + recomandareInterna Employee? @relation("Recomandari", fields: [recomandareInternaId], references: [id]) + recomandat Employee[] @relation("Recomandari") + + // Связанные сущности + identityDocuments IdentityDocument[] + familyMembers FamilyMember[] + educations Education[] + qualifications Qualification[] + trainings Training[] + disciplinarySanctions DisciplinarySanction[] + contracts EmploymentContract[] + benefit Benefit? + evaluationForms EvaluationForm[] + medicalProfile EmployeeMedicalProfile? + medicalCheckups MedicalCheckup[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([idnp]) + @@index([nume, prenume]) + @@index([status]) + @@index([dataNasterii]) + @@map("employees") +} + +// ═══════════════════════════════════════════════════════════════ +// B. IDENTITY DOCUMENT +// ═══════════════════════════════════════════════════════════════ + +model IdentityDocument { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + tipAct DocumentType + seria String? + nr String + dataEmiterii DateTime @db.Date + autoritateEmitenta String + dataExpirarii DateTime @db.Date + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Cron-задача за 30 дней до dataExpirarii → HR Inbox + @@index([employeeId]) + @@index([dataExpirarii]) + @@map("identity_documents") +} + +// ═══════════════════════════════════════════════════════════════ +// C. FAMILY MEMBERS +// ═══════════════════════════════════════════════════════════════ + +model FamilyMember { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + tip FamilyMemberType + numePrenume String + dataNasterii DateTime? @db.Date + idnp String? @db.VarChar(13) + telefon String? // обязателен для contact_principal — проверка на service-уровне + + // Скидки FISC (только для copil) + tipScutireId String? + tipScutire TaxExemption? @relation(fields: [tipScutireId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@map("family_members") +} + +// ═══════════════════════════════════════════════════════════════ +// D. EDUCATION +// ═══════════════════════════════════════════════════════════════ + +model Education { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + tipStudii StudyType + institutia String + specialitatea String + dataAbsolvirii DateTime? @db.Date + nrSeriaDiploma String? + dataEmiterii DateTime? @db.Date + nrInregistrare String? + confirmare DiplomaStatus? + nivel StudyLevel? + tipPostuniversitar PostUniversityType? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@map("educations") +} + +// ═══════════════════════════════════════════════════════════════ +// E. QUALIFICATIONS +// ═══════════════════════════════════════════════════════════════ + +model Qualification { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + categorie QualificationCategory + dataObtinerii DateTime? @db.Date + dataUltimeiConfirmari DateTime? @db.Date + dataExpirarii DateTime? @db.Date + specialitate String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Cron-задача за 90/30/7 дней до dataExpirarii → HR + manager + @@index([employeeId]) + @@index([dataExpirarii]) + @@map("qualifications") +} + +// ═══════════════════════════════════════════════════════════════ +// F. TRAINING +// ═══════════════════════════════════════════════════════════════ + +model Training { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + denumire String + inceput DateTime @db.Date + sfirsit DateTime? @db.Date + tip TrainingType + tara String? + nrOre Int? + organizatia String? + certificat Boolean @default(false) + cost Decimal? @db.Decimal(10, 2) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@map("trainings") +} + +// ═══════════════════════════════════════════════════════════════ +// G. DISCIPLINARY SANCTIONS +// ═══════════════════════════════════════════════════════════════ + +model DisciplinarySanction { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + tip DisciplinarySanctionType + dataAplicarii DateTime @db.Date + // auto-calc: dataAplicarii + 6 months — вычисляется на service-уровне при создании + dataExpirarii DateTime @db.Date + // set true cron-ом после dataExpirarii; до этого — активна при расчёте performance + isStinsa Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@index([dataExpirarii]) + @@map("disciplinary_sanctions") +} + +// ═══════════════════════════════════════════════════════════════ +// H. BENEFITS +// ═══════════════════════════════════════════════════════════════ + +model Benefit { + id String @id @default(uuid()) + employeeId String @unique + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + + uniformaId String? + uniforma InventoryItem? @relation("BenefitUniforma", fields: [uniformaId], references: [id]) + halatId String? + halat InventoryItem? @relation("BenefitHalat", fields: [halatId], references: [id]) + ciupiciId String? + ciupici InventoryItem? @relation("BenefitCiupici", fields: [ciupiciId], references: [id]) + vestaId String? + vesta InventoryItem? @relation("BenefitVesta", fields: [vestaId], references: [id]) + + ticheteMasa Boolean @default(false) + valoareTichet Decimal? @db.Decimal(10, 2) + alimentatiePersonal Boolean @default(false) + abonamentTel Decimal? @db.Decimal(10, 2) + aparatTelefonId String? + aparatTelefon InventoryItem? @relation("BenefitAparatTel", fields: [aparatTelefonId], references: [id]) + cardCompanie String? + automobilServiciu String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("benefits") +} + +enum InventoryItemType { + uniforma + halat + ciupici + vesta + aparat_telefon + alte +} + +model InventoryItem { + id String @id @default(uuid()) + sku String @unique + name String + type InventoryItemType + size String? + color String? + pricePerUnit Decimal? @db.Decimal(10, 2) + stockQty Int @default(0) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + uniformaBenefits Benefit[] @relation("BenefitUniforma") + halatBenefits Benefit[] @relation("BenefitHalat") + ciupiciBenefits Benefit[] @relation("BenefitCiupici") + vestaBenefits Benefit[] @relation("BenefitVesta") + aparatTelBenefits Benefit[] @relation("BenefitAparatTel") + + @@index([type, active]) + @@map("inventory_items") +} + +// ═══════════════════════════════════════════════════════════════ +// PHASE 2 STUB: EMPLOYMENT CONTRACT +// ═══════════════════════════════════════════════════════════════ + +model EmploymentContract { + id String @id @default(uuid()) + nrCim String @unique + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + categorie ContractCategory + dataSemnarii DateTime @db.Date + dataAngajarii DateTime @db.Date + dataDemisiei DateTime? @db.Date + perioada ContractPeriod + dataTerminarii DateTime? @db.Date + functiaClasificator String? // CORM код + codFunctie String? + functiaOrganigrama String? + tipCim ContractType + departmentId String + department Department @relation(fields: [departmentId], references: [id]) + regimMunca String? + tipSalarizare SalaryType? + // Условные поля salariu_fix / pe_ore / in_acord хранятся как JSONB + salarizareDetails Json? + clausaAditionala Json? + workScheduleId String? + workSchedule WorkSchedule? @relation(fields: [workScheduleId], references: [id]) + + categoriiServicii CimServiceCategory[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Бизнес-правило: zile_concediu = MAX среди всех CIM сотрудника — проверка на service-уровне + @@index([employeeId]) + @@index([departmentId]) + @@index([dataDemisiei]) + @@map("employment_contracts") +} + +model CimServiceCategory { + id String @id @default(uuid()) + contractId String + contract EmploymentContract @relation(fields: [contractId], references: [id], onDelete: Cascade) + categorieId String + tipRemunerare String // 'tarif' | 'procent' + sumaNeta Decimal? @db.Decimal(10, 2) + procent Decimal? @db.Decimal(5, 2) + + @@index([contractId]) + @@map("cim_service_categories") +} + +// ═══════════════════════════════════════════════════════════════ +// PHASE 4 STUB: PERFORMANCE EVALUATION +// ═══════════════════════════════════════════════════════════════ + +model EvaluationCampaign { + id String @id @default(uuid()) + name String + departmentId String + department Department @relation(fields: [departmentId], references: [id]) + month DateTime @db.Date // первый день месяца кампании + status CampaignStatus @default(draft) + forms EvaluationForm[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([departmentId]) + @@index([month]) + @@map("evaluation_campaigns") +} + +model EvaluationForm { + id String @id @default(uuid()) + campaignId String + campaign EvaluationCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + + // A. Competente clinice (slab/mediu/bine) + abilitatiClinice EvaluationScore? + judecataClinica EvaluationScore? + manopere EvaluationScore? + gestionareaSarcinilor EvaluationScore? + + // B. Comunicare si empatie + constiintaProfesionala EvaluationScore? + atitudineaPacienti EvaluationScore? + atitudineaColegi EvaluationScore? + atitudineaPersonalNonMed EvaluationScore? + + // C. Disciplina + utilizareSmartphone EvaluationScore? + respectareaProgramului EvaluationScore? + respectareaDressCode EvaluationScore? + + // D. Documentatie si complianta + testJci Json? // { score, max_score, percent, completed_at, source, external_id } + completareaDocMed Boolean? + perfectioneazaCunostinte Boolean? + + // E. Candidat EXPERT (Da/Nu) + membruComitetCalitate Boolean? + functieDeMonitor Boolean? + inlocuiesteSuperiorul Boolean? + + // F. Verdict final + categorieCalculata ProposedCategory? + categorieAprobata ProposedCategory? // override de nursing_director + observatii String? + + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([campaignId, employeeId]) + @@index([campaignId]) + @@index([employeeId]) + @@map("evaluation_forms") +} + +// ═══════════════════════════════════════════════════════════════ +// PHASE 5 STUB: MEDICAL CONTROL +// ═══════════════════════════════════════════════════════════════ + +model WorkplaceRiskCard { + id String @id @default(uuid()) + name String @unique // "Medic profil chirurgical cu gărzi de noapte" + riskFactors Json? // legacy: { chimici, fizici, biologici, ergonomici, psihosociali } + profiles EmployeeMedicalProfile[] + + // ── Anexa 4 — antet (Fișa de evaluare a riscurilor profesionale) ── + filiala String? + adresaFiliala String? + telefonFiliala String? + caemPrimeleDouaCifre String? + cormSubgrupaMajora String? + directiaSectiaSectorul String? + numarulLoculuiDeMunca String? + caemDiviziune String? + clasaConditiilorDeMunca String? + numarLucratoriPosibili Int? + // STANDARD (Anexa 4) | DISTANTA_DIGITAL (Anexa 4A — muncă la distanță/platforme digitale) + tipFisa String @default("STANDARD") + + // ── Anexa 4 — bloc descriptiv (checkbox-uri / descrieri) ── + evaluareDetalii Json? + + // ── Anexa 4 — radiații ionizante (per loc de muncă) ── + radiatiiIonizante Boolean @default(false) + radiatiiGrupa String? // A | B + radiatiiAparatura String? + radiatiiSurse String? // inchise | deschise + radiatiiTipExpunere String? // X externă | gamma externă | internă | externă și internă + radiatiiMasuriProtectie String? + + // ── Anexa 4 — subsol ── + mijloaceProtectieColectiva String? + mijloaceProtectieIndividuala String? + echipamentLucru String? + observatii String? + anexeIgienicoSanitare Json? // { vestiar, chiuveta, wc, dus, salaMese, recreere } + + exposures WorkplaceRiskExposure[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("workplace_risk_cards") +} + +// Rând din tabelele factoriale ale Anexei 4 +model WorkplaceRiskExposure { + id String @id @default(uuid()) + cardId String + card WorkplaceRiskCard @relation(fields: [cardId], references: [id], onDelete: Cascade) + tip RiskExposureType + denumire String + cas String? // doar chimic / pulberi + einecs String? // doar chimic / pulberi + clasificare String? // doar agent biologic + zonaAfectata String? // vibrații / câmp EM / radiații optice + timpExpunere String? + vep String? // valoarea de expunere profesională + vlep String? // valoarea-limită de expunere profesională obligatorie + caracteristici String? + procesVerbal String? + + createdAt DateTime @default(now()) + + @@index([cardId]) + @@map("workplace_risk_exposures") +} + +model EmployeeMedicalProfile { + id String @id @default(uuid()) + employeeId String @unique + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + ocupatieCorm String? + workplaceRiskCardId String? + workplaceRiskCard WorkplaceRiskCard? @relation(fields: [workplaceRiskCardId], references: [id]) + dataUltimControlMedical DateTime? @db.Date + + // Câmpuri radiații ionizante + expusRadiatiiIonizante Boolean @default(false) + dataIntrarii DateTime? @db.Date + expunereAnterioaraPerioda String? // se completează o singură dată la angajare + expunereAnterioaraAni Int? + dozaCumulataExternaMsv Decimal? @db.Decimal(10, 4) + dozaCumulataInternaMsv Decimal? @db.Decimal(10, 4) + // dozaTotalaMsv = externa + interna — câmp calculat, nu stocat + + // Supraexpuneri excepționale/accidentale (Anexa 4B) + overexposures RadiationOverexposure[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("employee_medical_profiles") +} + +// Supraexpunere la radiații ionizante — rând din Anexa 4B (per lucrător) +model RadiationOverexposure { + id String @id @default(uuid()) + medicalProfileId String + medicalProfile EmployeeMedicalProfile @relation(fields: [medicalProfileId], references: [id], onDelete: Cascade) + fel OverexposureKind // EXCEPTIONALA | ACCIDENTALA + tipExpunere String? // X externă | gamma externă | internă | externă și internă + data DateTime? @db.Date + dozaMsv Decimal? @db.Decimal(10, 4) + createdAt DateTime @default(now()) + + @@index([medicalProfileId]) + @@map("radiation_overexposures") +} + +model MedicalCheckup { + id String @id @default(uuid()) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + tip MedicalCheckupType + dataPlanificata DateTime @db.Date + dataEfectuata DateTime? @db.Date + verdict MedicalVerdict? + recomandari String? + valabilPanaLa DateTime? @db.Date + semnatDe String? + // Ссылки на S3-документы: [{ name, url, type }] + documenteGenerate Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([employeeId]) + @@index([dataPlanificata]) + @@map("medical_checkups") +} + +// ═══════════════════════════════════════════════════════════════ +// AUDIT LOG — append-only, 5 ani retentie +// ═══════════════════════════════════════════════════════════════ + +model AuditLog { + id BigInt @id @default(autoincrement()) + ts DateTime @default(now()) + userId String + userRole String @db.VarChar(50) + ip String? @db.VarChar(45) // IPv4 или IPv6 + action String @db.VarChar(20) // READ | CREATE | UPDATE | DELETE | EXPORT + entity String @db.VarChar(50) + entityId String @db.VarChar(50) + field String? @db.VarChar(100) + // PII-значения шифруются на app-уровне (pgcrypto / KMS) перед записью + oldValue String? + newValue String? + reason String? // обязателен для READ медицинских данных (GDPR) + + @@index([userId]) + @@index([entity, entityId]) + @@index([ts]) + @@map("audit_logs") +} + +// ═══════════════════════════════════════════════════════════════ +// ANEXA TEMPLATE EDITOR +// ═══════════════════════════════════════════════════════════════ + +model AnexaTemplate { + id String @id @default(uuid()) + type AnexaType @unique + name String + contentJson Json + updatedById String + updatedAt DateTime @updatedAt + versions AnexaTemplateVersion[] + + @@map("anexa_templates") +} + +model AnexaTemplateVersion { + id String @id @default(uuid()) + templateId String + template AnexaTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) + contentJson Json + savedById String + savedAt DateTime @default(now()) + label String? + + @@index([templateId]) + @@map("anexa_template_versions") +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..5ab0054 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,738 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🌱 Seeding reference data...'); + + // ── Disability Grades (grade de dizabilitate MD) ───────────────── + await prisma.disabilityGrade.createMany({ + data: [ + { code: 'GRAD_I', name: 'Grad I (sever)' }, + { code: 'GRAD_II', name: 'Grad II (accentuat)' }, + { code: 'GRAD_III', name: 'Grad III (mediu)' }, + ], + skipDuplicates: true, + }); + console.log(' ✓ DisabilityGrade (3)'); + + // ── Tax Exemptions (scutiri Codul Fiscal RM) ───────────────────── + await prisma.taxExemption.createMany({ + data: [ + { code: 'PE', description: 'Scutire personală (art. 33 CF)' }, + { code: 'PI', description: 'Scutire personală majorată (art. 33 alin. 2 CF)' }, + { code: 'SO', description: 'Scutire pentru soț/soție (art. 34 CF)' }, + { code: 'MP1', description: 'Scutire pentru 1 copil minor (art. 35 CF)' }, + { code: 'MP2', description: 'Scutire pentru 2 copii minori (art. 35 CF)' }, + { code: 'MP3', description: 'Scutire pentru 3+ copii minori (art. 35 CF)' }, + { code: 'INVALID', description: 'Scutire persoană cu dizabilitate (art. 33 alin. 2 lit. a CF)' }, + ], + skipDuplicates: true, + }); + console.log(' ✓ TaxExemption (7)'); + + // ── Work Schedules ─────────────────────────────────────────────── + await prisma.workSchedule.createMany({ + data: [ + { name: '5/2 — 8h/zi', daysWork: 5, daysRest: 2, hoursPerDay: 8 }, + { name: '5/2 — 7h/zi', daysWork: 5, daysRest: 2, hoursPerDay: 7 }, + { name: 'Gărzi 24h (1/3)', daysWork: 1, daysRest: 3, hoursPerDay: 24 }, + { name: 'Gărzi 12h zi (1/1)', daysWork: 1, daysRest: 1, hoursPerDay: 12 }, + { name: 'Gărzi 12h noapte (1/1)',daysWork: 1, daysRest: 1, hoursPerDay: 12 }, + { name: '7/7 — 12h/zi', daysWork: 7, daysRest: 7, hoursPerDay: 12 }, + { name: 'Rotație 2/2 — 12h', daysWork: 2, daysRest: 2, hoursPerDay: 12 }, + ], + skipDuplicates: true, + }); + console.log(' ✓ WorkSchedule (7)'); + + // ── Departments — Medpark International Hospital ───────────────── + // Level 0: hospital root + const root = await prisma.department.upsert({ + where: { code: 'MEDPARK' }, + update: {}, + create: { name: 'Medpark International Hospital', code: 'MEDPARK' }, + }); + + // Helper to upsert a department + const dept = async (name: string, code: string, parentId?: string) => + prisma.department.upsert({ + where: { code }, + update: {}, + create: { name, code, parentId: parentId ?? null }, + }); + + // Administration + const admin = await dept('Administrare', 'ADMIN', root.id); + await dept('Resurse Umane', 'HR', admin.id); + await dept('Financiar-Contabil', 'FIN', admin.id); + await dept('Juridic', 'JUR', admin.id); + await dept('IT', 'IT', admin.id); + await dept('Achiziții', 'ACHIZ', admin.id); + + // Medical divisions + const med = await dept('Bloc Medical', 'MED', root.id); + + const terapie = await dept('Terapie și Medicină Internă', 'TERAP', med.id); + await dept('Cardiologie', 'CARDIO', terapie.id); + await dept('Gastroenterologie', 'GASTRO', terapie.id); + await dept('Endocrinologie', 'ENDO', terapie.id); + await dept('Neurologie', 'NEURO', terapie.id); + await dept('Pneumologie', 'PNEUMO', terapie.id); + await dept('Reumatologie', 'REUMA', terapie.id); + + const chir = await dept('Chirurgie', 'CHIR', med.id); + await dept('Chirurgie Generală', 'CHIR_GEN', chir.id); + await dept('Chirurgie Vasculară', 'CHIR_VAS', chir.id); + await dept('Ortopedie și Traumatologie','ORTOPED', chir.id); + await dept('Urologie', 'UROL', chir.id); + await dept('ORL', 'ORL', chir.id); + await dept('Oftalmologie', 'OFTALMO', chir.id); + + const ped = await dept('Pediatrie', 'PED', med.id); + await dept('Pediatrie Generală', 'PED_GEN', ped.id); + await dept('Neonatologie', 'NEONAT', ped.id); + + const obst = await dept('Obstetrică-Ginecologie', 'OBG', med.id); + await dept('Obstetrică', 'OBSTET', obst.id); + await dept('Ginecologie', 'GINECO', obst.id); + + await dept('Oncologie', 'ONCOL', med.id); + await dept('Hemodializă', 'HEMODIAL', med.id); + await dept('Psihiatrie', 'PSIHIAT', med.id); + await dept('Dermatologie', 'DERMA', med.id); + await dept('Medicină Sportivă și Reabilitare', 'REAB', med.id); + + // Diagnostics + const diag = await dept('Diagnostic', 'DIAG', root.id); + await dept('Laborator Clinic', 'LAB', diag.id); + await dept('Imagistică Medicală (CT/RMN/Rx)', 'IMAG', diag.id); + await dept('Endoscopie', 'ENDOSC', diag.id); + await dept('Ecografie', 'ECO', diag.id); + await dept('Cardiologie Funcțională (ECG/Holter)', 'ECG', diag.id); + + // Support + const suport = await dept('Servicii Suport', 'SUPORT', root.id); + await dept('Urgențe (UPU)', 'UPU', suport.id); + await dept('Anestezie și Terapie Intensivă (ATI)', 'ATI', suport.id); + await dept('Bloc Operator', 'BLOC_OP', suport.id); + await dept('Sterilizare', 'STERIL', suport.id); + await dept('Farmacie', 'FARMACIE', suport.id); + await dept('Nutriție și Dietetică', 'NUTRIT', suport.id); + await dept('Serviciu Social', 'SOC', suport.id); + await dept('Curățenie și Dezinfecție', 'CURATENIE',suport.id); + await dept('Securitate', 'SECUR', suport.id); + await dept('Tehnică Medicală', 'TEH_MED', suport.id); + + // Ambulatory + const ambul = await dept('Centru Ambulator', 'AMBUL', root.id); + await dept('Medicină de Familie', 'MED_FAM', ambul.id); + await dept('Consultații Specializate', 'CONSULT', ambul.id); + await dept('Fizioterapie', 'FIZIOTER', ambul.id); + + const deptCount = await prisma.department.count(); + console.log(` ✓ Department (${deptCount})`); + + // ── Anexa Templates — minimal seed ───────────────────────── + const heading = (text: string, level = 2) => ({ + type: 'heading', + attrs: { level, textAlign: 'center' }, + content: [{ type: 'text', text }], + }); + const para = (content: object[], textAlign: string = 'left') => ({ + type: 'paragraph', + attrs: { textAlign }, + content, + }); + const txt = (text: string, marks?: { type: string }[]) => + marks ? { type: 'text', text, marks } : { type: 'text', text }; + const chip = (key: string, label: string) => ({ + type: 'variableChip', + attrs: { key, label }, + }); + const cell = (content: object[]) => ({ type: 'tableCell', content }); + const row = (cells: object[]) => ({ type: 'tableRow', content: cells }); + const headerRow = (labels: string[]) => + row(labels.map((l) => cell([para([txt(l, [{ type: 'bold' }])], 'center')]))); + + // ── Anexa 3: Fișa de solicitare ───────────────────────────────── + const anexa3 = { + type: 'doc', + content: [ + heading('FIȘA DE SOLICITARE A EXAMENULUI MEDICAL'), + para([txt('Unitatea economică: '), chip('company.name', 'Denumirea unității')]), + para([txt('IDNO: '), chip('company.idno', 'IDNO'), txt(' Adresa: '), chip('company.address', 'Adresa')]), + para([txt('Tipul examenului: '), chip('tipExamen', 'Tipul examenului')]), + para([txt('Departament: '), chip('department.name', 'Departament'), txt(' Carta de risc: '), chip('riskCard.name', 'Carta de risc')]), + para([txt('Data: '), chip('document.date', 'Data documentului'), txt(' Nr.: '), chip('document.number', 'Număr')]), + para([txt('Lista angajaților:', [{ type: 'bold' }])]), + { + type: 'table', + attrs: { repeatRows: true }, + content: [ + headerRow(['Nr.', 'Nume Prenume', 'IDNP', 'Anul nașterii', 'Ocupația', 'Tipul examenului']), + row([ + cell([para([chip('row.index', 'Nr.')])]), + cell([para([chip('row.employeeName', 'Nume Prenume')])]), + cell([para([chip('row.idnp', 'IDNP')])]), + cell([para([chip('row.birthYear', 'Anul nașterii')])]), + cell([para([chip('row.occupation', 'Ocupația')])]), + cell([para([chip('row.tipExamen', 'Tipul examenului')])]), + ]), + ], + }, + ], + }; + + // ── Anexa 4: Fișa de evaluare a riscurilor profesionale (NU-10-MS-2026) ── + const cb = (key: string, label: string) => [chip(`a4.cb.${key}`, '☐'), txt(' ' + label)]; + const factorTable = (rowsKey: string, cols: string[], rowChips: string[]) => ({ + type: 'table', + attrs: { repeatRows: true, rowsKey }, + content: [ + headerRow(cols), + row(rowChips.map((k) => cell([para([chip(k, '—')])]))), + ], + }); + + const anexa4 = { + type: 'doc', + content: [ + // ── Antet ── + para([txt('Unitatea economică/instituția: '), chip('a4.unitatea', 'Denumirea unității')]), + para([txt('Adresa, telefon, fax, e-mail: '), chip('a4.adresa', 'Adresa')]), + para([txt('Filiala: '), chip('a4.filiala', '—'), txt(' Adresa filialei: '), chip('a4.adresaFiliala', '—'), txt(' CAEM (primele 2 cifre): '), chip('a4.caem2', '—')]), + heading('FIȘA de evaluare a riscurilor profesionale', 2), + para([txt('Ocupația (subgrupa majoră CORM): '), chip('a4.cormSubgrupa', '—')]), + para([txt('Direcția/secția/sectorul: '), chip('a4.directiaSectia', '—')]), + para([txt('Numărul locului de muncă: '), chip('a4.numarLoc', '—'), txt(' CAEM (nivel diviziune): '), chip('a4.caemDiviziune', '—')]), + para([txt('Numărul de lucrători care pot activa la acest loc de muncă: '), chip('a4.numarLucratori', '—'), txt(' Clasa condițiilor de muncă: '), chip('a4.clasa', '—')]), + + // ── Descrierea activității ── + heading('Descrierea activității', 3), + para([txt('Lucrul în echipă: '), ...cb('echipa', 'da'), txt(' Nr. ore/zi: '), chip('a4.val.oreZi', '—'), txt(' Nr. schimburi: '), chip('a4.val.schimburi', '—')]), + para([...cb('schimbNoapte', 'schimb de noapte'), txt(' '), ...cb('pauzeOrganizate', 'pauze organizate')]), + para([txt('Riscuri: '), ...cb('riscInfectare', 'infectare'), txt(' '), ...cb('riscElectrocutare', 'electrocutare'), txt(' '), ...cb('riscTensiuneInalta', 'tensiune înaltă'), txt(' '), ...cb('riscInecare', 'înecare'), txt(' '), ...cb('riscAsfixiere', 'asfixiere')]), + para([...cb('riscStrivire', 'strivire'), txt(' '), ...cb('riscTaiere', 'tăiere'), txt(' '), ...cb('riscIntepare', 'înțepare'), txt(' '), ...cb('riscLovire', 'lovire'), txt(' '), ...cb('riscMuscatura', 'mușcătură'), txt(' '), ...cb('riscMicrotraumatisme', 'microtraumatisme repetate')]), + para([txt('Conduce mașina instituției: '), ...cb('conduceMasina', 'da'), txt(' categorie: '), chip('a4.val.conduceMasinaCategorie', '—'), txt(' '), ...cb('conduceUtilajeIntrauzinal', 'conduce utilaje numai intrauzinal')]), + + // ── Spațiul de lucru ── + heading('Descrierea spațiului de lucru', 3), + para([txt('Dimensiunile încăperii: L '), chip('a4.val.spatiuL', '—'), txt(' l '), chip('a4.val.spatiul', '—'), txt(' H '), chip('a4.val.spatiuH', '—'), txt(' m')]), + para([txt('Suprafața de lucru: '), ...cb('suprafataVerticala', 'verticală'), txt(' '), ...cb('suprafataOrizontala', 'orizontală'), txt(' '), ...cb('suprafataOblica', 'oblică')]), + para([txt('Muncă: '), ...cb('muncaIzolare', 'în condiții de izolare'), txt(' '), ...cb('muncaInaltime', 'la înălțime'), txt(' '), ...cb('muncaInMiscare', 'în mișcare')]), + + // ── Efort fizic ── + heading('Efort fizic', 3), + para([txt('Poziție preponderent: '), ...cb('pozitieOrtostatica', 'ortostatică'), txt(' '), ...cb('pozitieAsezat', 'așezat'), txt(' '), ...cb('pozitieAplecata', 'aplecată'), txt(' '), ...cb('pozitieMixta', 'mixtă'), txt(' '), ...cb('pozitieFortata', 'forțată/nefiziologică')]), + para([txt('Suprasolicitări musculo-articulare (coloană): '), ...cb('coloanaCervicala', 'cervicală'), txt(' '), ...cb('coloanaToracala', 'toracală'), txt(' '), ...cb('coloanaLombara', 'lombară')]), + para([txt('Manipulare manuală a maselor: '), ...cb('manipulareRidicare', 'ridicare'), txt(' '), ...cb('manipulareCoborare', 'coborâre'), txt(' '), ...cb('manipulareImpingere', 'împingere'), txt(' '), ...cb('manipulareTragere', 'tragere'), txt(' '), ...cb('manipularePurtare', 'purtare'), txt(' '), ...cb('manipulareDeplasare', 'deplasare')]), + para([txt('Greutate maximă manipulată manual: '), chip('a4.val.greutateMaxima', '—')]), + para([txt('Suprasolicitări: '), ...cb('suprasolicitariVizuale', 'vizuale'), txt(' '), ...cb('suprasolicitariAuditive', 'auditive'), txt(' '), ...cb('suprasolicitariNeuropsihice', 'neuropsihosenzoriale')]), + + // ── Factori de risc cu tabel ── + heading('AGENȚI CHIMICI', 3), + para([...cb('chimici_da', 'da'), txt(' '), ...cb('chimici_nu', 'nu'), txt(' (se atașează Fișa cu date de securitate, în limba română)')]), + factorTable('chimici', + ['Agentul chimic', 'CAS', 'EINECS', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.cas', 'row.einecs', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + heading('PULBERI', 3), + para([...cb('pulberi_da', 'da'), txt(' '), ...cb('pulberi_nu', 'nu')]), + factorTable('pulberi', + ['Pulberi', 'CAS', 'EINECS', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.cas', 'row.einecs', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + heading('AGENȚI BIOLOGICI', 3), + para([...cb('biologici_da', 'da'), txt(' '), ...cb('biologici_nu', 'nu')]), + factorTable('biologici', + ['Agent biologic', 'Clasificare', 'Note'], + ['row.denumire', 'row.clasificare', 'row.caracteristici']), + + heading('ZGOMOT PROFESIONAL', 3), + para([...cb('zgomot_da', 'da'), txt(' '), ...cb('zgomot_nu', 'nu')]), + factorTable('zgomot', + ['Tipul', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + heading('VIBRAȚII MECANICE', 3), + para([...cb('vibratii_da', 'da'), txt(' '), ...cb('vibratii_nu', 'nu')]), + factorTable('vibratii', + ['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + // ── Microclimat (descriptiv) ── + heading('MICROCLIMAT', 3), + para([...cb('microclimatInterior', 'lucrări interior'), txt(' '), ...cb('microclimatExterior', 'lucru exterior/sub cerul liber')]), + para([txt('Radiații calorice (perioada rece): '), ...cb('radiatiiCaloriceRece', 'da'), txt(' Radiații calorice (perioada caldă): '), ...cb('radiatiiCaloriceCalda', 'da')]), + + // ── Radiații ionizante ── + heading('RADIAȚII IONIZANTE', 3), + para([...cb('radiatii_da', 'da'), txt(' '), ...cb('radiatii_nu', 'nu'), txt(' Grupa: '), chip('a4.rad.grupa', '—')]), + para([txt('Aparatură folosită: '), chip('a4.rad.aparatura', '—'), txt(' Surse: '), chip('a4.rad.surse', '—')]), + para([txt('Tip de expunere: '), chip('a4.rad.tipExpunere', '—'), txt(' Măsuri de protecție: '), chip('a4.rad.masuriProtectie', '—')]), + + // ── Câmp electromagnetic ── + heading('CÂMP ELECTROMAGNETIC', 3), + para([...cb('campEM_da', 'da'), txt(' '), ...cb('campEM_nu', 'nu')]), + factorTable('campEM', + ['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + // ── Iluminat ── + heading('ILUMINAT', 3), + para([...cb('iluminatSuficient', 'suficient'), txt(' '), ...cb('iluminatInsuficient', 'insuficient'), txt(' '), ...cb('iluminatNatural', 'natural'), txt(' '), ...cb('iluminatArtificial', 'artificial'), txt(' '), ...cb('iluminatMixt', 'mixt')]), + + // ── Radiații optice artificiale ── + heading('RADIAȚII OPTICE ARTIFICIALE', 3), + para([...cb('optice_da', 'da'), txt(' '), ...cb('optice_nu', 'nu')]), + factorTable('optice', + ['Tipul', 'Zona afectată', 'Timp expunere', 'VEP', 'VLEP obligatorie', 'Caracteristici'], + ['row.denumire', 'row.zona', 'row.timp', 'row.vep', 'row.vlep', 'row.caracteristici']), + + // ── Subsol ── + heading('Protecție și dotări', 3), + para([txt('Mijloace de protecție colectivă: '), chip('a4.protectieColectiva', '—')]), + para([txt('Mijloace de protecție individuală: '), chip('a4.protectieIndividuala', '—')]), + para([txt('Echipament de lucru: '), chip('a4.echipament', '—')]), + para([txt('Anexe igienico-sanitare: '), ...cb('anexe.vestiar', 'vestiar'), txt(' '), ...cb('anexe.chiuveta', 'chiuvetă'), txt(' '), ...cb('anexe.wc', 'WC'), txt(' '), ...cb('anexe.dus', 'duș'), txt(' '), ...cb('anexe.salaMese', 'sală de mese'), txt(' '), ...cb('anexe.recreere', 'spațiu de recreere')]), + para([txt('Observații: '), chip('a4.observatii', '—')]), + para([txt('Data completării: '), chip('document.date', 'Data')]), + para([txt('Angajatorul (nume, prenume, semnătura): ____________________')]), + para([txt('Instrucțiuni de completare: răspuns afirmativ [☑]; răspuns negativ [☐].', [{ type: 'italic' }])]), + ], + }; + + // ── Anexa 4B: Supliment radiații ionizante ─────────────────────── + const anexa4b = { + type: 'doc', + content: [ + heading('SUPLIMENT — EXPUNERE LA RADIAȚII IONIZANTE'), + para([txt('Unitatea economică: '), chip('company.name', 'Denumirea unității')]), + para([txt('Data: '), chip('document.date', 'Data documentului')]), + para([txt('Personal expus radiațiilor ionizante:', [{ type: 'bold' }])]), + { + type: 'table', + attrs: { repeatRows: true }, + content: [ + headerRow(['Nr.', 'Nume Prenume', 'IDNP', 'Data intrării', 'Perioada anterioară', 'Ani', 'Doza ext. (mSv)', 'Doza int. (mSv)', 'Total (mSv)']), + row([ + cell([para([chip('row.index', 'Nr.')])]), + cell([para([chip('row.employeeName', 'Nume Prenume')])]), + cell([para([chip('row.idnp', 'IDNP')])]), + cell([para([chip('row.entryDate', 'Data intrării')])]), + cell([para([chip('row.priorPeriod', 'Perioada anterioară')])]), + cell([para([chip('row.priorYears', 'Ani')])]), + cell([para([chip('row.externalMsv', 'Doza ext.')])]), + cell([para([chip('row.internalMsv', 'Doza int.')])]), + cell([para([chip('row.totalMsv', 'Total')])]), + ]), + ], + }, + ], + }; + + // ── Anexa 6: Verdict medic de familie (per-employee) ───────────── + const anexa6 = { + type: 'doc', + content: [ + heading('FIȘĂ DE APTITUDINE — VERDICTUL MEDICULUI DE FAMILIE'), + para([txt('Angajat: '), chip('employee.fullName', 'Nume Prenume')]), + para([txt('IDNP: '), chip('employee.idnp', 'IDNP'), txt(' Data nașterii: '), chip('employee.birthDate', 'Data nașterii')]), + para([txt('Ocupația: '), chip('employee.occupation', 'Ocupația'), txt(' Departament: '), chip('employee.department', 'Departament')]), + para([txt('Tipul examenului: '), chip('tipExamen', 'Tipul examenului')]), + para([txt('Data examinării: '), chip('document.date', 'Data')]), + para([txt('Verdict:', [{ type: 'bold' }])]), + para([chip('verdict.checkbox.apt', '☐'), txt(' Apt')]), + para([chip('verdict.checkbox.apt_perioada_adaptare', '☐'), txt(' Apt în perioada de adaptare')]), + para([chip('verdict.checkbox.apt_conditionat', '☐'), txt(' Apt condiționat')]), + para([chip('verdict.checkbox.inapt_temporar', '☐'), txt(' Inapt temporar')]), + para([chip('verdict.checkbox.inapt', '☐'), txt(' Inapt')]), + para([txt('Recomandări: '), chip('verdict.recomandari', 'Recomandări')]), + para([txt(' ')]), + para([txt('Semnătura medicului de familie: ____________________')]), + ], + }; + + const SYS = '00000000-0000-0000-0000-000000000000'; + const templates: Array<{ type: 'ANEXA_3' | 'ANEXA_4' | 'ANEXA_4B' | 'ANEXA_6'; name: string; doc: object }> = [ + { type: 'ANEXA_3', name: 'Fișa de solicitare a examenului medical', doc: anexa3 }, + { type: 'ANEXA_4', name: 'Fișa de evaluare a locului de muncă', doc: anexa4 }, + { type: 'ANEXA_4B', name: 'Supliment radiații ionizante', doc: anexa4b }, + { type: 'ANEXA_6', name: 'Verdict medic de familie', doc: anexa6 }, + ]; + + for (const t of templates) { + await prisma.anexaTemplate.upsert({ + where: { type: t.type }, + update: { name: t.name, contentJson: t.doc as never }, + create: { type: t.type, name: t.name, contentJson: t.doc as never, updatedById: SYS }, + }); + } + console.log(' ✓ AnexaTemplate (4)'); + + // ── Inventory items (depozit Vestimentație + Echipament) ───────── + const inventory = [ + { sku: 'UN-CHIR-S-AL', name: 'Uniformă chirurgie S albastru', type: 'uniforma' as const, size: 'S', color: 'albastru', stockQty: 50 }, + { sku: 'UN-CHIR-M-AL', name: 'Uniformă chirurgie M albastru', type: 'uniforma' as const, size: 'M', color: 'albastru', stockQty: 50 }, + { sku: 'UN-CHIR-L-AL', name: 'Uniformă chirurgie L albastru', type: 'uniforma' as const, size: 'L', color: 'albastru', stockQty: 50 }, + { sku: 'UN-ATI-M-VE', name: 'Uniformă ATI M verde', type: 'uniforma' as const, size: 'M', color: 'verde', stockQty: 30 }, + { sku: 'HA-MED-M-AL', name: 'Halat medical M alb', type: 'halat' as const, size: 'M', color: 'alb', stockQty: 50 }, + { sku: 'HA-MED-L-AL', name: 'Halat medical L alb', type: 'halat' as const, size: 'L', color: 'alb', stockQty: 50 }, + { sku: 'HA-LAB-M-AL', name: 'Halat laborator M alb', type: 'halat' as const, size: 'M', color: 'alb', stockQty: 30 }, + { sku: 'HA-LAB-L-AL', name: 'Halat laborator L alb', type: 'halat' as const, size: 'L', color: 'alb', stockQty: 30 }, + { sku: 'CI-38-AL', name: 'Ciupici 38-40 albi', type: 'ciupici' as const, size: '38-40', color: 'alb', stockQty: 80 }, + { sku: 'CI-41-AL', name: 'Ciupici 41-43 albi', type: 'ciupici' as const, size: '41-43', color: 'alb', stockQty: 80 }, + { sku: 'CI-44-AL', name: 'Ciupici 44-46 albi', type: 'ciupici' as const, size: '44-46', color: 'alb', stockQty: 80 }, + { sku: 'VE-S-TE', name: 'Vestă S teal', type: 'vesta' as const, size: 'S', color: 'teal', stockQty: 20 }, + { sku: 'VE-M-TE', name: 'Vestă M teal', type: 'vesta' as const, size: 'M', color: 'teal', stockQty: 20 }, + { sku: 'AT-SAMS-A15', name: 'Samsung Galaxy A15', type: 'aparat_telefon' as const, stockQty: 15 }, + { sku: 'AT-IPHONE-SE', name: 'iPhone SE 2022', type: 'aparat_telefon' as const, stockQty: 10 }, + ]; + for (const item of inventory) { + await prisma.inventoryItem.upsert({ + where: { sku: item.sku }, + update: {}, + create: item, + }); + } + console.log(` ✓ InventoryItem (${inventory.length})`); + + // ── Demo data pentru prezentare ────────────────────────────── + console.log('\n🎭 Seeding demo data...'); + + // Risk cards + const chirExposures = [ + { tip: 'AGENT_CHIMIC' as const, denumire: 'Glutaraldehidă (dezinfectant)', cas: '111-30-8', einecs: '203-856-5', timpExpunere: '2 h/zi', vep: '0,03 ppm', vlep: '0,1 ppm', caracteristici: 'iritant respirator' }, + { tip: 'AGENT_BIOLOGIC' as const, denumire: 'Virusuri hematogene (HBV, HCV, HIV)', clasificare: 'grupa 3', caracteristici: 'risc de infectare prin înțepare/tăiere' }, + ]; + const chirHeader = { + filiala: 'Sediul central', + caemPrimeleDouaCifre: '86', + cormSubgrupaMajora: 'Personal medical — secție chirurgie', + directiaSectiaSectorul: 'Bloc Medical / Chirurgie Generală', + numarulLoculuiDeMunca: 'CH-01', + caemDiviziune: '86.10', + clasaConditiilorDeMunca: '3.2', + numarLucratoriPosibili: 12, + evaluareDetalii: { + echipa: true, oreZi: '8', schimburi: '2', schimbNoapte: true, pauzeOrganizate: true, + riscInfectare: true, riscTaiere: true, riscIntepare: true, + pozitieOrtostatica: true, manipulareRidicare: true, suprasolicitariVizuale: true, + }, + anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true }, + mijloaceProtectieIndividuala: 'Mănuși, mască, halat steril', + echipamentLucru: 'Uniformă chirurgicală', + }; + const rcChir = await prisma.workplaceRiskCard.upsert({ + where: { name: 'Secție chirurgie generală' }, + update: { ...chirHeader, exposures: { deleteMany: {}, create: chirExposures } }, + create: { name: 'Secție chirurgie generală', ...chirHeader, exposures: { create: chirExposures } }, + }); + + const imagExposures = [ + { tip: 'CAMP_ELECTROMAGNETIC' as const, denumire: 'Câmp electromagnetic RMN', zonaAfectata: 'corp întreg', timpExpunere: '4 h/zi', vep: '—', vlep: 'conform NU-10', caracteristici: 'câmp magnetic static intens' }, + ]; + const imagHeader = { + filiala: 'Sediul central', + caemPrimeleDouaCifre: '86', + cormSubgrupaMajora: 'Personal imagistică medicală', + directiaSectiaSectorul: 'Diagnostic / Imagistică Medicală', + numarulLoculuiDeMunca: 'IMG-01', + caemDiviziune: '86.90', + clasaConditiilorDeMunca: '3.3', + numarLucratoriPosibili: 8, + radiatiiIonizante: true, + radiatiiGrupa: 'A', + radiatiiSurse: 'închise', + radiatiiTipExpunere: 'X externă', + radiatiiAparatura: 'CT, aparat Rx', + radiatiiMasuriProtectie: 'șorț cu plumb, ecran de protecție, dozimetru individual', + evaluareDetalii: { + echipa: true, oreZi: '7', schimburi: '2', + riscElectrocutare: true, pozitieAsezat: true, suprasolicitariVizuale: true, + }, + anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true }, + mijloaceProtectieIndividuala: 'Șorț cu plumb, ochelari, dozimetru', + }; + const rcImag = await prisma.workplaceRiskCard.upsert({ + where: { name: 'Radiologie și imagistică' }, + update: { ...imagHeader, exposures: { deleteMany: {}, create: imagExposures } }, + create: { name: 'Radiologie și imagistică', ...imagHeader, exposures: { create: imagExposures } }, + }); + console.log(' ✓ WorkplaceRiskCard demo (2) — cu antet Anexa 4 + factori'); + + // Lookup departments & inventory items + const chirGenDept = await prisma.department.findUnique({ where: { code: 'CHIR_GEN' } }); + const imagDept = await prisma.department.findUnique({ where: { code: 'IMAG' } }); + const uniformaS = await prisma.inventoryItem.findUnique({ where: { sku: 'UN-CHIR-S-AL' } }); + const halatM = await prisma.inventoryItem.findUnique({ where: { sku: 'HA-MED-M-AL' } }); + + // 4 demo employees (IDNPs pre-validated cu algoritmul de sumă de control MD) + const emp1 = await prisma.employee.upsert({ + where: { idnp: '1985061500016' }, + update: {}, + create: { + idnp: '1985061500016', nume: 'Popescu', prenume: 'Alexandru', + sex: 'M', dataNasterii: new Date('1985-06-15'), + domiciliu: 'mun. Chișinău, str. Ștefan cel Mare 1', + telefonPersonal: '+37369100001', status: 'activ', + }, + }); + const emp2 = await prisma.employee.upsert({ + where: { idnp: '1990032200017' }, + update: {}, + create: { + idnp: '1990032200017', nume: 'Ionescu', prenume: 'Maria', + sex: 'F', dataNasterii: new Date('1990-03-22'), + domiciliu: 'mun. Chișinău, str. Mihai Viteazul 5', + telefonPersonal: '+37369100002', status: 'activ', + }, + }); + const emp3 = await prisma.employee.upsert({ + where: { idnp: '1978110800016' }, + update: {}, + create: { + idnp: '1978110800016', nume: 'Rusu', prenume: 'Viorel', + sex: 'M', dataNasterii: new Date('1978-11-08'), + domiciliu: 'mun. Chișinău, str. Alba Iulia 12', + telefonPersonal: '+37369100003', status: 'activ', + }, + }); + const emp4 = await prisma.employee.upsert({ + where: { idnp: '2001091400010' }, + update: {}, + create: { + idnp: '2001091400010', nume: 'Cojocaru', prenume: 'Elena', + sex: 'F', dataNasterii: new Date('2001-09-14'), + domiciliu: 'mun. Chișinău, str. Trandafirilor 3', + telefonPersonal: '+37369100004', status: 'activ', + }, + }); + console.log(' ✓ Employee demo (4)'); + + // Employment contracts + if (chirGenDept) { + await prisma.employmentContract.upsert({ + where: { nrCim: 'CIM-DEMO-001' }, + update: {}, + create: { + nrCim: 'CIM-DEMO-001', employeeId: emp1.id, + categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata', + dataSemnarii: new Date('2020-01-10'), dataAngajarii: new Date('2020-01-15'), + departmentId: chirGenDept.id, functiaOrganigrama: 'Chirurg', + salarizareDetails: { tip: 'fix', salariu: 18000, zileConcediu: 28 }, + }, + }); + await prisma.employmentContract.upsert({ + where: { nrCim: 'CIM-DEMO-002' }, + update: {}, + create: { + nrCim: 'CIM-DEMO-002', employeeId: emp2.id, + categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata', + dataSemnarii: new Date('2021-03-01'), dataAngajarii: new Date('2021-03-05'), + departmentId: chirGenDept.id, functiaOrganigrama: 'Asistentă medicală', + salarizareDetails: { tip: 'fix', salariu: 10000, zileConcediu: 28 }, + }, + }); + } + if (imagDept) { + await prisma.employmentContract.upsert({ + where: { nrCim: 'CIM-DEMO-003' }, + update: {}, + create: { + nrCim: 'CIM-DEMO-003', employeeId: emp3.id, + categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata', + dataSemnarii: new Date('2018-06-01'), dataAngajarii: new Date('2018-06-10'), + departmentId: imagDept.id, functiaOrganigrama: 'Radiolog', + salarizareDetails: { tip: 'fix', salariu: 20000, zileConcediu: 35 }, + }, + }); + await prisma.employmentContract.upsert({ + where: { nrCim: 'CIM-DEMO-004' }, + update: {}, + create: { + nrCim: 'CIM-DEMO-004', employeeId: emp4.id, + categorie: 'principal', tipCim: 'de_baza', perioada: 'nedeterminata', + dataSemnarii: new Date('2023-09-01'), dataAngajarii: new Date('2023-09-15'), + departmentId: imagDept.id, functiaOrganigrama: 'Asistentă radiologie', + salarizareDetails: { tip: 'fix', salariu: 9500, zileConcediu: 28 }, + }, + }); + } + console.log(' ✓ EmploymentContract demo (4)'); + + // Medical profiles: + // emp1 — chirurgie, niciodată examinat + // emp2 — chirurgie, examinat acum 15 luni (expirat) + // emp3 — radiologie, examinat acum 11 luni + radiații (expiră curând) + // emp4 — radiologie, niciodată examinat + radiații + await prisma.employeeMedicalProfile.upsert({ + where: { employeeId: emp1.id }, update: {}, + create: { employeeId: emp1.id, workplaceRiskCardId: rcChir.id, expusRadiatiiIonizante: false }, + }); + await prisma.employeeMedicalProfile.upsert({ + where: { employeeId: emp2.id }, update: {}, + create: { + employeeId: emp2.id, workplaceRiskCardId: rcChir.id, + dataUltimControlMedical: new Date('2025-02-14'), + expusRadiatiiIonizante: false, + }, + }); + const emp3Radiatii = { + workplaceRiskCardId: rcImag.id, + dataUltimControlMedical: new Date('2025-06-14'), + expusRadiatiiIonizante: true, + dataIntrarii: new Date('2019-02-01'), + expunereAnterioaraPerioda: '2015–2018', + expunereAnterioaraAni: 3, + dozaCumulataExternaMsv: 4.2500, + dozaCumulataInternaMsv: 0.8000, + }; + const emp3Supra = [ + { fel: 'EXCEPTIONALA' as const, tipExpunere: 'X externă', data: new Date('2023-05-12'), dozaMsv: 2.5000 }, + { fel: 'ACCIDENTALA' as const, tipExpunere: 'gamma externă', data: new Date('2024-09-03'), dozaMsv: 1.2000 }, + ]; + await prisma.employeeMedicalProfile.upsert({ + where: { employeeId: emp3.id }, + update: { ...emp3Radiatii, overexposures: { deleteMany: {}, create: emp3Supra } }, + create: { employeeId: emp3.id, ...emp3Radiatii, overexposures: { create: emp3Supra } }, + }); + await prisma.employeeMedicalProfile.upsert({ + where: { employeeId: emp4.id }, update: {}, + create: { employeeId: emp4.id, workplaceRiskCardId: rcImag.id, expusRadiatiiIonizante: true }, + }); + console.log(' ✓ EmployeeMedicalProfile demo (4)'); + + // Pending checkups for inbox (verdict = null) + // emp1 — la_angajare, acum 5 zile (depășit → roșu) + // emp2 — periodic, peste 3 zile + // emp3 — la_reluarea_activitatii, mâine + const day = (offsetDays: number) => { + const d = new Date('2026-05-14'); + d.setDate(d.getDate() + offsetDays); + return d; + }; + for (const [empId, tip, offset] of [ + [emp1.id, 'la_angajare', -5], + [emp2.id, 'periodic', 3], + [emp3.id, 'la_reluarea_activitatii', 1], + ] as [string, string, number][]) { + const exists = await prisma.medicalCheckup.findFirst({ where: { employeeId: empId, verdict: null } }); + if (!exists) { + await prisma.medicalCheckup.create({ + data: { employeeId: empId, tip: tip as never, dataPlanificata: day(offset) }, + }); + } + } + console.log(' ✓ MedicalCheckup demo — pending inbox (3)'); + + // ── Evaluation campaigns demo (modulul de evaluare nursing) ────── + // Campania A — Chirurgie Generală, IN_PROGRESS: + // emp2 (Ionescu Maria) — formular complet, scoruri bune + 1 criteriu EXPERT + // → categorie calculată "superioara", ÎNCĂ NEAPROBATĂ + // (nursing_director o poate aproba — demo aprobare) + // emp1 (Popescu Alexandru) — formular parțial (în lucru) → "fara" + if (chirGenDept) { + const campMonth = new Date('2026-05-01'); + let camp = await prisma.evaluationCampaign.findFirst({ + where: { departmentId: chirGenDept.id, month: campMonth }, + }); + if (!camp) { + camp = await prisma.evaluationCampaign.create({ + data: { + name: 'Evaluare anuală nursing — Chirurgie Generală 2026', + departmentId: chirGenDept.id, + month: campMonth, + status: 'in_progress', + }, + }); + } + + await prisma.evaluationForm.upsert({ + where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp2.id } }, + update: {}, + create: { + campaignId: camp.id, employeeId: emp2.id, + abilitatiClinice: 'bine', judecataClinica: 'bine', manopere: 'bine', gestionareaSarcinilor: 'mediu', + constiintaProfesionala: 'bine', atitudineaPacienti: 'bine', atitudineaColegi: 'bine', atitudineaPersonalNonMed: 'mediu', + utilizareSmartphone: 'bine', respectareaProgramului: 'bine', respectareaDressCode: 'bine', + testJci: { score: 18, max_score: 20, percent: 90, completed_at: '2026-05-10', source: 'academy_ocean', external_id: 'AO-DEMO-001' }, + completareaDocMed: true, perfectioneazaCunostinte: true, + membruComitetCalitate: true, functieDeMonitor: false, inlocuiesteSuperiorul: false, + categorieCalculata: 'superioara', + }, + }); + + await prisma.evaluationForm.upsert({ + where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp1.id } }, + update: {}, + create: { + campaignId: camp.id, employeeId: emp1.id, + abilitatiClinice: 'bine', judecataClinica: 'mediu', manopere: 'mediu', + categorieCalculata: 'fara', + }, + }); + console.log(' ✓ EvaluationCampaign demo — Chirurgie (in_progress, 2 formulare)'); + } + + // Campania B — Imagistică, CLOSED (istoric read-only): + // emp3 (Rusu Viorel) — formular finalizat și aprobat → "cat_I" + if (imagDept) { + const campMonth = new Date('2025-11-01'); + let camp = await prisma.evaluationCampaign.findFirst({ + where: { departmentId: imagDept.id, month: campMonth }, + }); + if (!camp) { + camp = await prisma.evaluationCampaign.create({ + data: { + name: 'Evaluare anuală nursing — Imagistică 2025', + departmentId: imagDept.id, + month: campMonth, + status: 'closed', + }, + }); + } + await prisma.evaluationForm.upsert({ + where: { campaignId_employeeId: { campaignId: camp.id, employeeId: emp3.id } }, + update: {}, + create: { + campaignId: camp.id, employeeId: emp3.id, + abilitatiClinice: 'bine', judecataClinica: 'bine', manopere: 'bine', gestionareaSarcinilor: 'bine', + constiintaProfesionala: 'bine', atitudineaPacienti: 'mediu', atitudineaColegi: 'bine', atitudineaPersonalNonMed: 'bine', + utilizareSmartphone: 'bine', respectareaProgramului: 'bine', respectareaDressCode: 'mediu', + completareaDocMed: true, perfectioneazaCunostinte: true, + membruComitetCalitate: false, functieDeMonitor: false, inlocuiesteSuperiorul: false, + categorieCalculata: 'cat_I', + categorieAprobata: 'cat_I', + observatii: 'Performanță constantă, recomandat pentru categoria I.', + completedAt: new Date('2025-11-20'), + }, + }); + console.log(' ✓ EvaluationCampaign demo — Imagistică (closed, 1 formular aprobat)'); + } + + // Benefit cu vestimentație pentru emp1 + if (uniformaS && halatM) { + await prisma.benefit.upsert({ + where: { employeeId: emp1.id }, + update: {}, + create: { + employeeId: emp1.id, + uniformaId: uniformaS.id, + halatId: halatM.id, + ticheteMasa: true, + valoareTichet: 65, + alimentatiePersonal: false, + abonamentTel: 150, + }, + }); + console.log(' ✓ Benefit demo (1) — Popescu Alexandru: uniformă + halat'); + } + + console.log('\n✅ Seed complete.'); +} + +main() + .catch((e) => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/scripts/generate-docx-stubs.ts b/apps/api/scripts/generate-docx-stubs.ts new file mode 100644 index 0000000..0b40520 --- /dev/null +++ b/apps/api/scripts/generate-docx-stubs.ts @@ -0,0 +1,226 @@ +/** + * Generează BOLĂVANKE (stub) .docx pentru Anexele 3/4/4A/4B/6 cu TOATE placeholder-ele + * docxtemplater din `templates/docx/README.md`. Formatarea o ajustați apoi în Word. + * + * Rulare: pnpm --filter api exec ts-node scripts/generate-docx-stubs.ts + */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { + Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, + HeadingLevel, WidthType, BorderStyle, +} from 'docx'; + +const OUT = join(__dirname, '..', 'templates', 'docx'); +mkdirSync(OUT, { recursive: true }); + +// ── helpers ── +const T = (text: string, bold = false) => new TextRun({ text, bold }); +const ph = (name: string) => new TextRun({ text: `{${name}}`, bold: true, color: '0B6E70' }); +const P = (...children: TextRun[]) => new Paragraph({ children }); +const H = (text: string, level: (typeof HeadingLevel)[keyof typeof HeadingLevel] = HeadingLevel.HEADING_2) => + new Paragraph({ heading: level, children: [T(text, true)] }); +const empty = () => new Paragraph({ children: [] }); +// "Label {ph}" +const line = (label: string, name: string) => P(T(label + ' '), ph(name)); +// checkbox: "{cbX} Label" +const cb = (name: string, label: string) => [ph(name), T(' ' + label + ' ')]; +const cbLine = (...pairs: [string, string][]) => + P(...pairs.flatMap(([n, l]) => cb(n, l))); + +const BORDER = { + top: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + left: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + right: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, +}; +const cell = (children: Paragraph[]) => new TableCell({ children, borders: BORDER }); +const headerRow = (labels: string[]) => + new TableRow({ children: labels.map((l) => cell([P(T(l, true))])) }); + +/** + * Tabel repetabil: rândul-șablon repetă pentru fiecare element din `loop`. + * `{#loop}` în prima celulă, `{/loop}` în ultima. + */ +function loopTable(loop: string, headers: string[], rowFields: string[]): Table { + const tplCells = rowFields.map((f, i) => { + const runs: TextRun[] = []; + if (i === 0) runs.push(new TextRun({ text: `{#${loop}}`, bold: true, color: 'B11116' })); + runs.push(ph(f)); + if (i === rowFields.length - 1) runs.push(new TextRun({ text: `{/${loop}}`, bold: true, color: 'B11116' })); + return cell([P(...runs)]); + }); + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [headerRow(headers), new TableRow({ children: tplCells })], + }); +} + +function save(name: string, children: (Paragraph | Table)[]) { + const doc = new Document({ sections: [{ children }] }); + return Packer.toBuffer(doc).then((buf) => { + writeFileSync(join(OUT, name), buf); + console.log(' ✓', name, `(${buf.length} bytes)`); + }); +} + +// ════════════════════════ ANEXA 3 ════════════════════════ +const anexa3: (Paragraph | Table)[] = [ + H('FIȘA de solicitare a examenului medical'), + line('Unitatea economică/instituția:', 'unitatea'), + P(T('IDNO: '), ph('idno'), T(' Adresa: '), ph('adresa')), + P(T('Telefon: '), ph('telefon'), T(' Fax: '), ph('fax'), T(' E-mail: '), ph('email')), + P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' Telefon: '), ph('telefonFiliala')), + empty(), + loopTable('angajati', + ['Nr.', 'Numele și prenumele', 'Anul nașterii', 'IDNP', 'Tipul examenului', 'Ocupația (CORM)', 'CAEM', 'Nr. loc muncă', 'Factorul de risc'], + ['nr', 'numePrenume', 'anNastere', 'idnp', 'tipExamen', 'ocupatieCorm', 'caem', 'numarLoc', 'factorRisc']), + empty(), + line('Data completării:', 'dataCompletarii'), + P(T('Solicitant: '), ph('solicitant'), T(' Funcția: '), ph('functia')), + P(T('Semnătura: ____________________')), +]; + +// ════════════════════════ ANEXA 4 ════════════════════════ +const anexa4Header: (Paragraph | Table)[] = [ + line('Unitatea economică/instituția:', 'unitatea'), + P(T('Adresa: '), ph('adresa')), + P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' CAEM (2 cifre): '), ph('caem2')), + H('FIȘA de evaluare a riscurilor profesionale'), + line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'), + line('Direcția/secția/sectorul:', 'directiaSectia'), + P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune')), + P(T('Nr. lucrători care pot activa: '), ph('numarLucratori'), T(' Clasa condițiilor de muncă: '), ph('clasa')), +]; +const anexa4Descriptiv: (Paragraph | Table)[] = [ + H('Descrierea activității', HeadingLevel.HEADING_3), + P(T('Lucru în echipă '), ph('cbEchipa'), T(' Nr. ore/zi: '), ph('oreZi'), T(' Nr. schimburi: '), ph('schimburi')), + cbLine(['cbSchimbNoapte', 'schimb de noapte'], ['cbPauze', 'pauze organizate']), + P(T('Riscuri:')), + cbLine(['cbInfectare', 'infectare'], ['cbElectrocutare', 'electrocutare'], ['cbTensiuneInalta', 'tensiune înaltă'], ['cbInecare', 'înecare'], ['cbAsfixiere', 'asfixiere']), + cbLine(['cbStrivire', 'strivire'], ['cbTaiere', 'tăiere'], ['cbIntepare', 'înțepare'], ['cbLovire', 'lovire'], ['cbMuscatura', 'mușcătură'], ['cbMicrotraumatisme', 'microtraumatisme']), + P(T('Conduce mașina '), ph('cbConduceMasina'), T(' categorie: '), ph('categorieConducere'), T(' '), ph('cbUtilajeIntrauzinal'), T(' utilaje intrauzinal')), + H('Descrierea spațiului de lucru', HeadingLevel.HEADING_3), + P(T('Dimensiuni: L '), ph('spatiuL'), T(' l '), ph('spatiul'), T(' H '), ph('spatiuH'), T(' m')), + cbLine(['cbSuprafVerticala', 'suprafață verticală'], ['cbSuprafOrizontala', 'orizontală'], ['cbSuprafOblica', 'oblică']), + cbLine(['cbMuncaIzolare', 'în izolare'], ['cbMuncaInaltime', 'la înălțime'], ['cbMuncaMiscare', 'în mișcare']), + H('Efort fizic', HeadingLevel.HEADING_3), + P(T('Poziție: ')), + cbLine(['cbPozitieOrtostatica', 'ortostatică'], ['cbPozitieAsezat', 'așezat'], ['cbPozitieAplecata', 'aplecată'], ['cbPozitieMixta', 'mixtă'], ['cbPozitieFortata', 'forțată']), + P(T('Suprasolicitări coloană: ')), + cbLine(['cbColoanaCervicala', 'cervicală'], ['cbColoanaToracala', 'toracală'], ['cbColoanaLombara', 'lombară']), + P(T('Manipulare manuală: ')), + cbLine(['cbManipRidicare', 'ridicare'], ['cbManipCoborare', 'coborâre'], ['cbManipImpingere', 'împingere'], ['cbManipTragere', 'tragere'], ['cbManipPurtare', 'purtare'], ['cbManipDeplasare', 'deplasare']), + P(T('Greutate maximă manipulată: '), ph('greutateMaxima')), + cbLine(['cbVizuale', 'suprasolicitări vizuale'], ['cbAuditive', 'auditive'], ['cbNeuropsihice', 'neuropsihice']), +]; +const factorTables: (Paragraph | Table)[] = [ + H('AGENȚI CHIMICI', HeadingLevel.HEADING_3), + loopTable('chimici', ['Agentul chimic', 'CAS', 'EINECS', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], + ['denumire', 'cas', 'einecs', 'timp', 'vep', 'vlep', 'caracteristici']), + H('PULBERI', HeadingLevel.HEADING_3), + loopTable('pulberi', ['Pulberi', 'CAS', 'EINECS', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], + ['denumire', 'cas', 'einecs', 'timp', 'vep', 'vlep', 'caracteristici']), + H('AGENȚI BIOLOGICI', HeadingLevel.HEADING_3), + loopTable('biologici', ['Agent biologic', 'Clasificare', 'Note'], ['denumire', 'clasificare', 'note']), + H('ZGOMOT PROFESIONAL', HeadingLevel.HEADING_3), + loopTable('zgomot', ['Tipul', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'timp', 'vep', 'vlep', 'caracteristici']), + H('VIBRAȚII MECANICE', HeadingLevel.HEADING_3), + loopTable('vibratii', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']), + H('CÂMP ELECTROMAGNETIC', HeadingLevel.HEADING_3), + loopTable('campEM', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']), + H('RADIAȚII OPTICE ARTIFICIALE', HeadingLevel.HEADING_3), + loopTable('optice', ['Tipul', 'Zona', 'Timp', 'VEP', 'VLEP', 'Caracteristici'], ['denumire', 'zona', 'timp', 'vep', 'vlep', 'caracteristici']), +]; +const anexa4Footer: (Paragraph | Table)[] = [ + H('MICROCLIMAT / RADIAȚII / ILUMINAT', HeadingLevel.HEADING_3), + cbLine(['cbMicroclimatInterior', 'interior'], ['cbMicroclimatExterior', 'exterior'], ['cbCaloriceRece', 'rad. calorice (rece)'], ['cbCaloriceCalda', 'rad. calorice (caldă)']), + P(T('Radiații ionizante '), ph('cbRadiatii'), T(' Grupa: '), ph('radGrupa'), T(' Surse: '), ph('radSurse')), + P(T('Tip expunere: '), ph('radTipExpunere'), T(' Aparatură: '), ph('radAparatura'), T(' Măsuri: '), ph('radMasuri')), + cbLine(['cbIluminatSuficient', 'iluminat suficient'], ['cbIluminatInsuficient', 'insuficient'], ['cbIluminatNatural', 'natural'], ['cbIluminatArtificial', 'artificial'], ['cbIluminatMixt', 'mixt']), + H('Protecție și dotări', HeadingLevel.HEADING_3), + line('Mijloace de protecție colectivă:', 'protectieColectiva'), + line('Mijloace de protecție individuală:', 'protectieIndividuala'), + line('Echipament de lucru:', 'echipament'), + P(T('Anexe igienico-sanitare: ')), + cbLine(['cbVestiar', 'vestiar'], ['cbChiuveta', 'chiuvetă'], ['cbWc', 'WC'], ['cbDus', 'duș'], ['cbSalaMese', 'sală de mese'], ['cbRecreere', 'recreere']), + line('Observații:', 'observatii'), + line('Data completării:', 'dataCompletarii'), + P(T('Angajatorul (nume, prenume, semnătura): ____________________')), + P(new TextRun({ text: 'Instrucțiuni: răspuns afirmativ [☑]; răspuns negativ [☐].', italics: true })), +]; + +// ════════════════════════ ANEXA 4A ════════════════════════ +const anexa4a: (Paragraph | Table)[] = [ + line('Unitatea economică/instituția:', 'unitatea'), + P(T('Adresa: '), ph('adresa'), T(' Filiala: '), ph('filiala'), T(' CAEM (2 cifre): '), ph('caem2')), + H('FIȘA de evaluare — muncă la distanță / platforme digitale'), + line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'), + line('Direcția/secția/sectorul:', 'directiaSectia'), + P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune'), T(' Clasa: '), ph('clasa')), + H('Descrierea activității', HeadingLevel.HEADING_3), + P(T('Lucru în echipă '), ph('cbEchipa'), T(' Nr. ore/zi: '), ph('oreZi'), T(' Nr. schimburi: '), ph('schimburi')), + cbLine(['cbSchimbNoapte', 'schimb de noapte'], ['cbPauze', 'pauze organizate'], ['cbLucruMonitor', 'lucru la monitor'], ['cbPlatformeDigitale', 'platforme digitale']), + P(T('Conduce mașina '), ph('cbConduceMasina'), T(' categorie: '), ph('categorieConducere')), + line('Operațiuni executate:', 'operatiuni'), + P(T('Deplasări pe teren '), ph('cbDeplasari'), T(' '), ph('deplasariDescriere')), + H('Efort fizic', HeadingLevel.HEADING_3), + P(T('Manipulare manuală: ')), + cbLine(['cbManipRidicare', 'ridicare'], ['cbManipCoborare', 'coborâre'], ['cbManipImpingere', 'împingere'], ['cbManipTragere', 'tragere'], ['cbManipPurtare', 'purtare'], ['cbManipDeplasare', 'deplasare']), + P(T('Greutate maximă: '), ph('greutateMaxima')), + cbLine(['cbVizuale', 'vizuale'], ['cbAuditive', 'auditive'], ['cbNeuropsihice', 'neuropsihice']), + line('Alte riscuri:', 'alteRiscuri'), + line('Data completării:', 'dataCompletarii'), + P(T('Angajatorul (nume, prenume, semnătura): ____________________')), +]; + +// ════════════════════════ ANEXA 4B ════════════════════════ +const anexa4b: (Paragraph | Table)[] = [ + line('Unitatea economică/instituția:', 'unitatea'), + P(T('Adresa: '), ph('adresa'), T(' Telefon: '), ph('telefon'), T(' Fax: '), ph('fax'), T(' E-mail: '), ph('email')), + P(T('Filiala: '), ph('filiala'), T(' Adresa filialei: '), ph('adresaFiliala'), T(' CAEM (2 cifre): '), ph('caem2')), + H('SUPLIMENT la Fișa de evaluare a riscurilor profesionale'), + line('Ocupația (subgrupa majoră CORM):', 'cormSubgrupa'), + line('Direcția/secția/sectorul:', 'directiaSectia'), + P(T('Numărul locului de muncă: '), ph('numarLoc'), T(' CAEM (diviziune): '), ph('caemDiviziune')), + P(T('Numele, prenumele lucrătorului: '), ph('numePrenume'), T(' IDNP: '), ph('idnp')), + P(T('RADIAȚII IONIZANTE: '), ph('cbRadiatii')), + line('Data intrării în mediul cu expunere:', 'dataIntrarii'), + P(T('Expunere anterioară — perioada: '), ph('expAnterioaraPerioada'), T(' ani: '), ph('expAnterioaraAni')), + P(T('Doză externă (mSv): '), ph('dozaExterna'), T(' Doză internă (mSv): '), ph('dozaInterna'), T(' Doză totală (mSv): '), ph('dozaTotala')), + H('Supraexpuneri excepționale', HeadingLevel.HEADING_3), + loopTable('supraexpExceptionale', ['Tip de expunere', 'Data', 'Doză (mSv)'], ['tipExpunere', 'data', 'doza']), + H('Supraexpuneri accidentale', HeadingLevel.HEADING_3), + loopTable('supraexpAccidentale', ['Tip de expunere', 'Data', 'Doză (mSv)'], ['tipExpunere', 'data', 'doza']), + line('Data completării:', 'dataCompletarii'), + P(T('Angajatorul (nume, prenume, semnătura): ____________________')), +]; + +// ════════════════════════ ANEXA 6 ════════════════════════ +const anexa6: (Paragraph | Table)[] = [ + H('FIȘĂ DE APTITUDINE ÎN MUNCĂ'), + line('Unitatea:', 'unitatea'), + P(T('Angajat: '), ph('numePrenume'), T(' IDNP: '), ph('idnp'), T(' Anul nașterii: '), ph('anNastere')), + P(T('Ocupația: '), ph('ocupatieCorm'), T(' Departament: '), ph('departament')), + P(T('Tipul examenului: '), ph('tipExamen'), T(' Data: '), ph('dataCompletarii')), + H('Verdict', HeadingLevel.HEADING_3), + P(ph('cbApt'), T(' Apt')), + P(ph('cbAptAdaptare'), T(' Apt în perioada de adaptare')), + P(ph('cbAptConditionat'), T(' Apt condiționat')), + P(ph('cbInaptTemporar'), T(' Inapt temporar')), + P(ph('cbInapt'), T(' Inapt')), + line('Recomandări:', 'recomandari'), + line('Valabil până la:', 'valabilPanaLa'), + P(T('Semnătura medicului: '), ph('semnatDe')), +]; + +async function main() { + console.log('📄 Generez bolăvanke .docx în', OUT); + await save('anexa-3.docx', anexa3); + await save('anexa-4.docx', [...anexa4Header, ...anexa4Descriptiv, ...factorTables, ...anexa4Footer]); + await save('anexa-4a.docx', anexa4a); + await save('anexa-4b.docx', anexa4b); + await save('anexa-6.docx', anexa6); + console.log('✅ Gata. Editați formatarea în Word — placeholder-ele rămân ca {nume}.'); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/api/scripts/seed-test-data.ts b/apps/api/scripts/seed-test-data.ts new file mode 100644 index 0000000..d6e5500 --- /dev/null +++ b/apps/api/scripts/seed-test-data.ts @@ -0,0 +1,662 @@ +import { + AnexaType, + CampaignStatus, + ContractCategory, + ContractPeriod, + ContractType, + DisciplinarySanctionType, + DiplomaStatus, + DocumentType, + EmployeeStatus, + EvaluationScore, + FamilyMemberType, + InventoryItemType, + MedicalCheckupType, + MedicalVerdict, + OverexposureKind, + PrismaClient, + ProposedCategory, + QualificationCategory, + RiskExposureType, + SalaryType, + ScientificTitle, + Sex, + StudyLevel, + PostUniversityType, + StudyType, + TrainingType, +} from '@prisma/client'; + +const prisma = new PrismaClient(); + +function dbNameFromUrl(url: string): string { + return decodeURIComponent(new URL(url).pathname.replace(/^\//, '')); +} + +function requireTemporaryDatabase() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is required for test seed'); + const dbName = dbNameFromUrl(url); + if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') { + throw new Error(`Refusing to seed non-test database "${dbName}". Expected hrm_medpark_test_*.`); + } + return dbName; +} + +const d = (value: string) => new Date(`${value}T00:00:00.000Z`); + +async function resetData() { + await prisma.auditLog.deleteMany(); + await prisma.anexaTemplateVersion.deleteMany(); + await prisma.anexaTemplate.deleteMany(); + await prisma.radiationOverexposure.deleteMany(); + await prisma.medicalCheckup.deleteMany(); + await prisma.employeeMedicalProfile.deleteMany(); + await prisma.workplaceRiskExposure.deleteMany(); + await prisma.workplaceRiskCard.deleteMany(); + await prisma.evaluationForm.deleteMany(); + await prisma.evaluationCampaign.deleteMany(); + await prisma.cimServiceCategory.deleteMany(); + await prisma.employmentContract.deleteMany(); + await prisma.benefit.deleteMany(); + await prisma.disciplinarySanction.deleteMany(); + await prisma.training.deleteMany(); + await prisma.qualification.deleteMany(); + await prisma.education.deleteMany(); + await prisma.familyMember.deleteMany(); + await prisma.identityDocument.deleteMany(); + await prisma.employee.deleteMany(); + await prisma.inventoryItem.deleteMany(); + await prisma.department.deleteMany(); + await prisma.workSchedule.deleteMany(); + await prisma.taxExemption.deleteMany(); + await prisma.disabilityGrade.deleteMany(); +} + +async function main() { + const dbName = requireTemporaryDatabase(); + console.log(`Seeding Medpark test data into ${dbName}...`); + await resetData(); + + const disability = await prisma.disabilityGrade.create({ + data: { code: 'TEST-GR-I', name: 'Grad dizabilitate I - test' }, + }); + const childTax = await prisma.taxExemption.create({ + data: { code: 'TEST-SCUTIRE-COPIL', description: 'Scutire copil - test' }, + }); + const schedule = await prisma.workSchedule.create({ + data: { name: 'Test 5/2 8h', daysWork: 5, daysRest: 2, hoursPerDay: 8 }, + }); + const shiftSchedule = await prisma.workSchedule.create({ + data: { name: 'Test 12/24', daysWork: 1, daysRest: 1, hoursPerDay: 12 }, + }); + + const root = await prisma.department.create({ data: { name: 'Medpark Test', code: 'TEST_ROOT' } }); + const surgeryDept = await prisma.department.create({ + data: { name: 'Chirurgie Test', code: 'TEST_CHIR', parentId: root.id }, + }); + const radiologyDept = await prisma.department.create({ + data: { name: 'Radiologie Test', code: 'TEST_RAD', parentId: root.id }, + }); + const remoteDept = await prisma.department.create({ + data: { name: 'Administrativ Digital Test', code: 'TEST_REMOTE', parentId: root.id }, + }); + const labDept = await prisma.department.create({ + data: { name: 'Laborator Test', code: 'TEST_LAB', parentId: root.id }, + }); + + const uniform = await prisma.inventoryItem.create({ + data: { sku: 'TEST-UNIFORM-M', name: 'Uniformă test M', type: InventoryItemType.uniforma, size: 'M', color: 'teal', stockQty: 20 }, + }); + const coat = await prisma.inventoryItem.create({ + data: { sku: 'TEST-HALAT-L', name: 'Halat test L', type: InventoryItemType.halat, size: 'L', color: 'alb', stockQty: 20 }, + }); + const shoes = await prisma.inventoryItem.create({ + data: { sku: 'TEST-CIUPICI-40', name: 'Ciupici test 40', type: InventoryItemType.ciupici, size: '40', color: 'alb', stockQty: 20 }, + }); + const phone = await prisma.inventoryItem.create({ + data: { sku: 'TEST-PHONE-A15', name: 'Telefon test Samsung A15', type: InventoryItemType.aparat_telefon, stockQty: 5 }, + }); + + const commonEval = { + echipa: true, + oreZi: '8', + schimburi: '2', + schimbNoapte: true, + pauzeOrganizate: true, + riscInfectare: true, + riscElectrocutare: true, + riscTensiuneInalta: false, + riscInecare: false, + riscAsfixiere: false, + riscStrivire: true, + riscTaiere: true, + riscIntepare: true, + riscLovire: true, + riscMuscatura: false, + riscMicrotraumatisme: true, + conduceMasina: true, + conduceMasinaCategorie: 'B', + conduceUtilajeIntrauzinal: false, + spatiuL: '4', + spatiul: '5', + spatiuH: '3', + suprafataVerticala: false, + suprafataOrizontala: true, + suprafataOblica: false, + muncaIzolare: false, + muncaInaltime: true, + muncaInMiscare: true, + pozitieOrtostatica: true, + pozitieAsezat: false, + pozitieAplecata: true, + pozitieMixta: true, + pozitieFortata: false, + coloanaCervicala: true, + coloanaToracala: true, + coloanaLombara: true, + manipulareRidicare: true, + manipulareCoborare: true, + manipulareImpingere: true, + manipulareTragere: false, + manipularePurtare: true, + manipulareDeplasare: true, + greutateMaxima: '15 kg', + suprasolicitariVizuale: true, + suprasolicitariAuditive: true, + suprasolicitariNeuropsihice: true, + microclimatInterior: true, + microclimatExterior: false, + radiatiiCaloriceRece: false, + radiatiiCaloriceCalda: false, + iluminatSuficient: true, + iluminatInsuficient: false, + iluminatNatural: true, + iluminatArtificial: true, + iluminatMixt: true, + }; + + const surgeryCard = await prisma.workplaceRiskCard.create({ + data: { + name: 'Test - Medic profil chirurgical cu gărzi de noapte', + riskFactors: { source: 'Control medical (5).docx', categories: ['chimici', 'biologici', 'fizici', 'ergonomici'] }, + filiala: 'Sediul central', + adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău', + telefonFiliala: '+373 22 000 101', + caemPrimeleDouaCifre: '86', + cormSubgrupaMajora: 'Personal medical profil chirurgical', + directiaSectiaSectorul: 'Bloc operator / Chirurgie', + numarulLoculuiDeMunca: 'TEST-CHIR-01', + caemDiviziune: '86.10', + clasaConditiilorDeMunca: '3.2', + numarLucratoriPosibili: 12, + tipFisa: 'STANDARD', + evaluareDetalii: commonEval, + radiatiiIonizante: false, + mijloaceProtectieColectiva: 'Ventilație locală, containere pentru obiecte ascuțite', + mijloaceProtectieIndividuala: 'Mănuși, mască, halat steril, vizieră', + echipamentLucru: 'Uniformă chirurgicală', + anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true, recreere: true }, + observatii: 'Set complet pentru testarea Anexa 4.', + exposures: { + create: [ + { tip: RiskExposureType.AGENT_CHIMIC, denumire: 'Glutaraldehidă', cas: '111-30-8', einecs: '203-856-5', timpExpunere: '2 h/zi', vep: '0,03 ppm', vlep: '0,1 ppm', caracteristici: 'iritant respirator' }, + { tip: RiskExposureType.PULBERI, denumire: 'Pulberi textile sterile', cas: '—', einecs: '—', timpExpunere: '1 h/zi', vep: '2 mg/m3', vlep: '5 mg/m3', caracteristici: 'pulberi inhalabile' }, + { tip: RiskExposureType.AGENT_BIOLOGIC, denumire: 'HBV/HCV/HIV', clasificare: 'grupa 3', caracteristici: 'risc prin înțepare/tăiere' }, + { tip: RiskExposureType.ZGOMOT, denumire: 'Echipamente bloc operator', timpExpunere: '4 h/zi', vep: '80 dB', vlep: '87 dB', caracteristici: 'zgomot intermitent' }, + { tip: RiskExposureType.VIBRATII, denumire: 'Instrumentar oscilant', zonaAfectata: 'mână-braț', timpExpunere: '30 min/zi', vep: '2,5 m/s2', vlep: '5 m/s2', caracteristici: 'vibrații locale' }, + { tip: RiskExposureType.CAMP_ELECTROMAGNETIC, denumire: 'Electrocauter', zonaAfectata: 'corp întreg', timpExpunere: '1 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'câmp EM local' }, + { tip: RiskExposureType.RADIATII_OPTICE, denumire: 'Lămpi chirurgicale', zonaAfectata: 'ochi', timpExpunere: '6 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'lumină intensă' }, + ], + }, + }, + }); + + const radiologyCard = await prisma.workplaceRiskCard.create({ + data: { + name: 'Test - Radiologie cu radiații ionizante', + riskFactors: { source: 'Control medical (5).docx', categories: ['radiații ionizante', 'câmp electromagnetic'] }, + filiala: 'Sediul central', + adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău', + telefonFiliala: '+373 22 000 102', + caemPrimeleDouaCifre: '86', + cormSubgrupaMajora: 'Personal imagistică medicală', + directiaSectiaSectorul: 'Diagnostic / Radiologie', + numarulLoculuiDeMunca: 'TEST-RAD-01', + caemDiviziune: '86.90', + clasaConditiilorDeMunca: '3.3', + numarLucratoriPosibili: 8, + tipFisa: 'STANDARD', + evaluareDetalii: { ...commonEval, riscInfectare: false, pozitieAsezat: true, pozitieOrtostatica: false }, + radiatiiIonizante: true, + radiatiiGrupa: 'A', + radiatiiAparatura: 'CT, Rx digital', + radiatiiSurse: 'închise', + radiatiiTipExpunere: 'X externă', + radiatiiMasuriProtectie: 'Ecran de protecție, șorț plumb, dozimetru individual', + mijloaceProtectieColectiva: 'Ecrane plumbate și semnalizare zonă controlată', + mijloaceProtectieIndividuala: 'Șorț plumb, ochelari, dozimetru', + echipamentLucru: 'Uniformă radiologie', + anexeIgienicoSanitare: { vestiar: true, chiuveta: true, wc: true, dus: true, salaMese: true, recreere: false }, + exposures: { + create: [ + { tip: RiskExposureType.CAMP_ELECTROMAGNETIC, denumire: 'Câmp electromagnetic RMN', zonaAfectata: 'corp întreg', timpExpunere: '4 h/zi', vep: 'conform NU-10', vlep: 'conform NU-10', caracteristici: 'câmp magnetic static intens' }, + { tip: RiskExposureType.ZGOMOT, denumire: 'Aparatură imagistică', timpExpunere: '3 h/zi', vep: '75 dB', vlep: '87 dB', caracteristici: 'zgomot tehnic' }, + ], + }, + }, + }); + + const remoteCard = await prisma.workplaceRiskCard.create({ + data: { + name: 'Test - Activități administrative la distanță', + riskFactors: { source: 'Control medical (5).docx', categories: ['vizual', 'neuropsihic', 'platforme digitale'] }, + filiala: 'Sediul central', + adresaFiliala: 'str. Nicolae Testemițanu 29, Chișinău', + telefonFiliala: '+373 22 000 103', + caemPrimeleDouaCifre: '86', + cormSubgrupaMajora: 'Personal administrativ', + directiaSectiaSectorul: 'Administrativ / Digital', + numarulLoculuiDeMunca: 'TEST-REMOTE-01', + caemDiviziune: '86.90', + clasaConditiilorDeMunca: '2', + numarLucratoriPosibili: 4, + tipFisa: 'DISTANTA_DIGITAL', + evaluareDetalii: { + echipa: false, + oreZi: '8', + schimburi: '1', + schimbNoapte: false, + pauzeOrganizate: true, + lucruMonitor: true, + platformeDigitale: true, + conduceMasina: false, + operatiuni: 'Operare HIS, e-mail, raportare digitală', + deplasari: true, + deplasariDescriere: 'Deplasări ocazionale la sediu', + manipulareRidicare: false, + manipulareCoborare: false, + manipulareImpingere: false, + manipulareTragere: false, + manipularePurtare: false, + manipulareDeplasare: false, + greutateMaxima: 'sub 3 kg', + suprasolicitariVizuale: true, + suprasolicitariAuditive: false, + suprasolicitariNeuropsihice: true, + alteRiscuri: 'Lucru prelungit la monitor', + }, + radiatiiIonizante: false, + mijloaceProtectieIndividuala: 'Scaun ergonomic, monitor extern', + echipamentLucru: 'Laptop corporativ', + anexeIgienicoSanitare: { vestiar: false, chiuveta: true, wc: true, dus: false, salaMese: false, recreere: true }, + observatii: 'Set pentru testarea Anexa 4A.', + }, + }); + + const employees = await Promise.all([ + prisma.employee.create({ + data: { + idnp: '1985061500016', + nume: 'Popescu', + prenume: 'Alexandru', + patronimic: 'Ion', + dataNasterii: d('1985-06-15'), + domiciliu: 'mun. Chișinău, str. Ștefan cel Mare 1', + adresaReala: 'mun. Chișinău, str. Test 1', + telefonPersonal: '+37369100001', + telefonServiciu: '+37322100001', + emailPersonal: 'alexandru.popescu.test@example.com', + emailCorporativ: 'alexandru.popescu@medpark.test', + sex: Sex.M, + codCpas: 'CPAS-001', + stareCivila: 'casatorit', + titluStiintific: ScientificTitle.doctor, + status: EmployeeStatus.activ, + }, + }), + prisma.employee.create({ + data: { + idnp: '1990032200017', + nume: 'Ionescu', + prenume: 'Maria', + patronimic: 'Vasile', + dataNasterii: d('1990-03-22'), + domiciliu: 'mun. Chișinău, str. Mihai Viteazul 5', + telefonPersonal: '+37369100002', + telefonServiciu: '+37322100002', + emailCorporativ: 'maria.ionescu@medpark.test', + sex: Sex.F, + status: EmployeeStatus.activ, + gradDizabilitateId: disability.id, + }, + }), + prisma.employee.create({ + data: { + idnp: '1978110800016', + nume: 'Rusu', + prenume: 'Viorel', + dataNasterii: d('1978-11-08'), + domiciliu: 'mun. Chișinău, str. Alba Iulia 12', + telefonPersonal: '+37369100003', + emailCorporativ: 'viorel.rusu@medpark.test', + sex: Sex.M, + status: EmployeeStatus.activ, + }, + }), + prisma.employee.create({ + data: { + idnp: '2001091400010', + nume: 'Cojocaru', + prenume: 'Elena', + dataNasterii: d('2001-09-14'), + domiciliu: 'mun. Chișinău, str. Trandafirilor 3', + telefonPersonal: '+37369100004', + emailCorporativ: 'elena.cojocaru@medpark.test', + sex: Sex.F, + status: EmployeeStatus.activ, + }, + }), + prisma.employee.create({ + data: { + idnp: '1995120100019', + nume: 'Munteanu', + prenume: 'Ana', + dataNasterii: d('1995-12-01'), + domiciliu: 'mun. Chișinău, bd. Dacia 20', + telefonPersonal: '+37369100005', + emailCorporativ: 'ana.munteanu@medpark.test', + sex: Sex.F, + status: EmployeeStatus.activ, + }, + }), + prisma.employee.create({ + data: { + idnp: '1989020300012', + nume: 'Lungu', + prenume: 'Sergiu', + dataNasterii: d('1989-02-03'), + domiciliu: 'mun. Chișinău, str. Laboratorului 7', + telefonPersonal: '+37369100006', + emailCorporativ: 'sergiu.lungu@medpark.test', + sex: Sex.M, + status: EmployeeStatus.activ, + }, + }), + ]); + + const [surgeon, nurse, radiologist, radiologyNurse, remoteAdmin, labDoctor] = employees; + + await prisma.identityDocument.createMany({ + data: employees.map((employee, index) => ({ + employeeId: employee.id, + tipAct: DocumentType.buletin_de_identitate, + seria: `T${index + 1}`, + nr: `TESTDOC${index + 1}`, + dataEmiterii: d('2021-01-10'), + autoritateEmitenta: 'ASP Test', + dataExpirarii: d(`2031-01-${10 + index}`), + })), + }); + + await prisma.familyMember.createMany({ + data: [ + { employeeId: surgeon.id, tip: FamilyMemberType.contact_principal, numePrenume: 'Popescu Elena', telefon: '+37368111111' }, + { employeeId: surgeon.id, tip: FamilyMemberType.copil, numePrenume: 'Popescu Andrei', dataNasterii: d('2015-05-20'), idnp: '2015052000015', tipScutireId: childTax.id }, + { employeeId: nurse.id, tip: FamilyMemberType.mama, numePrenume: 'Ionescu Tatiana', telefon: '+37368222222' }, + ], + }); + + await prisma.education.createMany({ + data: [ + { employeeId: surgeon.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Chirurgie', dataAbsolvirii: d('2008-06-30'), nrSeriaDiploma: 'DIP-TEST-001', dataEmiterii: d('2008-07-10'), nrInregistrare: 'REG-001', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat }, + { employeeId: nurse.id, tipStudii: StudyType.medii_de_specialitate, institutia: 'Colegiul Național de Medicină', specialitatea: 'Nursing', dataAbsolvirii: d('2012-06-30'), nrSeriaDiploma: 'DIP-TEST-002', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza }, + { employeeId: radiologist.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Radiologie', dataAbsolvirii: d('2002-06-30'), nrSeriaDiploma: 'DIP-TEST-003', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat }, + { employeeId: radiologyNurse.id, tipStudii: StudyType.medii_de_specialitate, institutia: 'Colegiul Național de Medicină', specialitatea: 'Radiologie', dataAbsolvirii: d('2022-06-30'), nrSeriaDiploma: 'DIP-TEST-004', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza }, + { employeeId: remoteAdmin.id, tipStudii: StudyType.superioare, institutia: 'ASEM', specialitatea: 'Management', dataAbsolvirii: d('2017-06-30'), nrSeriaDiploma: 'DIP-TEST-005', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.de_baza }, + { employeeId: labDoctor.id, tipStudii: StudyType.superioare, institutia: 'USMF Nicolae Testemițanu', specialitatea: 'Medicină de laborator', dataAbsolvirii: d('2013-06-30'), nrSeriaDiploma: 'DIP-TEST-006', confirmare: DiplomaStatus.confirmata, nivel: StudyLevel.postuniversitar, tipPostuniversitar: PostUniversityType.rezidentiat }, + ], + }); + + await prisma.qualification.createMany({ + data: [ + { employeeId: surgeon.id, categorie: QualificationCategory.superioara, dataObtinerii: d('2020-02-01'), dataUltimeiConfirmari: d('2024-02-01'), dataExpirarii: d('2029-02-01'), specialitate: 'Chirurgie' }, + { employeeId: nurse.id, categorie: QualificationCategory.cat_I, dataObtinerii: d('2021-03-01'), dataUltimeiConfirmari: d('2024-03-01'), dataExpirarii: d('2029-03-01'), specialitate: 'Nursing' }, + { employeeId: radiologist.id, categorie: QualificationCategory.superioara, dataObtinerii: d('2019-04-01'), dataUltimeiConfirmari: d('2024-04-01'), dataExpirarii: d('2029-04-01'), specialitate: 'Radiologie' }, + { employeeId: radiologyNurse.id, categorie: QualificationCategory.cat_II, dataObtinerii: d('2024-05-01'), dataUltimeiConfirmari: d('2024-05-01'), dataExpirarii: d('2029-05-01'), specialitate: 'Radiologie' }, + ], + }); + + await prisma.training.createMany({ + data: employees.map((employee, index) => ({ + employeeId: employee.id, + denumire: `Instruire Control medical test ${index + 1}`, + inceput: d('2026-01-10'), + sfirsit: d('2026-01-12'), + tip: index % 2 === 0 ? TrainingType.intern : TrainingType.extern_RM, + tara: 'Republica Moldova', + nrOre: 16, + organizatia: 'Medpark Academy Test', + certificat: true, + cost: '1000.00', + })), + }); + + await prisma.disciplinarySanction.createMany({ + data: [ + { employeeId: nurse.id, tip: DisciplinarySanctionType.avertisment, dataAplicarii: d('2026-02-01'), dataExpirarii: d('2026-08-01') }, + { employeeId: remoteAdmin.id, tip: DisciplinarySanctionType.mustrare, dataAplicarii: d('2025-09-01'), dataExpirarii: d('2026-03-01'), isStinsa: true }, + ], + }); + + const contractRows = [ + { employee: surgeon, dept: surgeryDept, nr: 'TEST-CIM-001', role: 'Chirurg', corm: '221201', card: surgeryCard, schedule: shiftSchedule }, + { employee: nurse, dept: surgeryDept, nr: 'TEST-CIM-002', role: 'Asistentă medicală chirurgie', corm: '222101', card: surgeryCard, schedule: shiftSchedule }, + { employee: radiologist, dept: radiologyDept, nr: 'TEST-CIM-003', role: 'Medic radiolog', corm: '221203', card: radiologyCard, schedule }, + { employee: radiologyNurse, dept: radiologyDept, nr: 'TEST-CIM-004', role: 'Asistentă radiologie', corm: '222102', card: radiologyCard, schedule }, + { employee: remoteAdmin, dept: remoteDept, nr: 'TEST-CIM-005', role: 'Specialist documente digitale', corm: '242101', card: remoteCard, schedule }, + { employee: labDoctor, dept: labDept, nr: 'TEST-CIM-006', role: 'Medic laborator', corm: '221207', card: surgeryCard, schedule }, + ]; + + for (const row of contractRows) { + const contract = await prisma.employmentContract.create({ + data: { + nrCim: row.nr, + employeeId: row.employee.id, + categorie: ContractCategory.principal, + dataSemnarii: d('2024-01-05'), + dataAngajarii: d('2024-01-15'), + perioada: ContractPeriod.nedeterminata, + functiaClasificator: row.corm, + codFunctie: row.corm, + functiaOrganigrama: row.role, + tipCim: ContractType.de_baza, + departmentId: row.dept.id, + regimMunca: 'normă întreagă', + tipSalarizare: SalaryType.fix, + salarizareDetails: { tip: 'fix', salariu: 15000 + contractRows.indexOf(row) * 500, zileConcediu: 28 }, + clausaAditionala: { test: true, source: 'Rubrici necesare (6).xlsx / CIM' }, + workScheduleId: row.schedule.id, + categoriiServicii: { + create: [ + { categorieId: `TEST-SERV-${contractRows.indexOf(row) + 1}`, tipRemunerare: 'tarif', sumaNeta: '250.00' }, + ], + }, + }, + }); + await prisma.auditLog.create({ + data: { userId: 'seed-test', userRole: 'hr_admin', action: 'CREATE', entity: 'EmploymentContract', entityId: contract.id }, + }); + } + + await prisma.benefit.createMany({ + data: [ + { + employeeId: nurse.id, + uniformaId: uniform.id, + halatId: coat.id, + ciupiciId: shoes.id, + ticheteMasa: true, + valoareTichet: '70.00', + alimentatiePersonal: true, + abonamentTel: '150.00', + aparatTelefonId: phone.id, + cardCompanie: 'TEST-CARD-001', + }, + { + employeeId: radiologyNurse.id, + uniformaId: uniform.id, + halatId: coat.id, + ticheteMasa: true, + valoareTichet: '70.00', + }, + ], + }); + + const profileByEmployeeId = new Map<string, string>(); + for (const row of contractRows) { + const radiology = row.card.id === radiologyCard.id; + const profile = await prisma.employeeMedicalProfile.create({ + data: { + employeeId: row.employee.id, + ocupatieCorm: `${row.role} (${row.corm})`, + workplaceRiskCardId: row.card.id, + dataUltimControlMedical: row.employee.id === surgeon.id ? null : d(row.employee.id === nurse.id ? '2025-02-01' : '2025-06-01'), + expusRadiatiiIonizante: radiology, + dataIntrarii: radiology ? d('2020-01-15') : null, + expunereAnterioaraPerioda: radiology ? '2017-2019' : null, + expunereAnterioaraAni: radiology ? 3 : null, + dozaCumulataExternaMsv: radiology ? '4.2500' : null, + dozaCumulataInternaMsv: radiology ? '0.8000' : null, + overexposures: radiology && row.employee.id === radiologist.id + ? { + create: [ + { fel: OverexposureKind.EXCEPTIONALA, tipExpunere: 'X externă', data: d('2023-05-12'), dozaMsv: '2.5000' }, + { fel: OverexposureKind.ACCIDENTALA, tipExpunere: 'gamma externă', data: d('2024-09-03'), dozaMsv: '1.2000' }, + ], + } + : undefined, + }, + }); + profileByEmployeeId.set(row.employee.id, profile.id); + } + + await prisma.medicalCheckup.createMany({ + data: [ + { employeeId: surgeon.id, tip: MedicalCheckupType.la_angajare, dataPlanificata: d('2026-05-20') }, + { employeeId: nurse.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2026-05-28') }, + { employeeId: radiologist.id, tip: MedicalCheckupType.la_reluarea_activitatii, dataPlanificata: d('2026-05-29') }, + { employeeId: surgeon.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-05-20'), dataEfectuata: d('2025-05-20'), verdict: MedicalVerdict.apt, recomandari: 'Control anual', valabilPanaLa: d('2026-05-20'), semnatDe: 'Dr. Test Apt', documenteGenerate: [{ name: 'Anexa_6_Final_Apt', url: 's3://test/anexa6_apt.docx', type: 'ANEXA_6' }] }, + { employeeId: nurse.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-04-20'), dataEfectuata: d('2025-04-20'), verdict: MedicalVerdict.apt_perioada_adaptare, recomandari: 'Adaptare 30 zile', valabilPanaLa: d('2026-04-20'), semnatDe: 'Dr. Test Adaptare', documenteGenerate: [{ name: 'Anexa_6_Final_Adaptare', url: 's3://test/anexa6_adaptare.docx', type: 'ANEXA_6' }] }, + { employeeId: radiologist.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-03-20'), dataEfectuata: d('2025-03-20'), verdict: MedicalVerdict.apt_conditionat, recomandari: 'Dozimetru obligatoriu', valabilPanaLa: d('2026-03-20'), semnatDe: 'Dr. Test Conditionat', documenteGenerate: [{ name: 'Anexa_6_Final_Conditionat', url: 's3://test/anexa6_conditionat.docx', type: 'ANEXA_6' }] }, + { employeeId: radiologyNurse.id, tip: MedicalCheckupType.suplimentar, dataPlanificata: d('2025-02-20'), dataEfectuata: d('2025-02-20'), verdict: MedicalVerdict.inapt_temporar, recomandari: 'Reevaluare peste 30 zile', valabilPanaLa: d('2025-03-20'), semnatDe: 'Dr. Test Temporar', documenteGenerate: [{ name: 'Anexa_6_Final_Temporar', url: 's3://test/anexa6_temporar.docx', type: 'ANEXA_6' }] }, + { employeeId: remoteAdmin.id, tip: MedicalCheckupType.periodic, dataPlanificata: d('2025-01-20'), dataEfectuata: d('2025-01-20'), verdict: MedicalVerdict.inapt, recomandari: 'Inapt pentru postul curent', valabilPanaLa: d('2025-02-20'), semnatDe: 'Dr. Test Inapt', documenteGenerate: [{ name: 'Anexa_6_Final_Inapt', url: 's3://test/anexa6_inapt.docx', type: 'ANEXA_6' }] }, + ], + }); + + const campaign = await prisma.evaluationCampaign.create({ + data: { + name: 'Test evaluare anuală nursing - Chirurgie 2026', + departmentId: surgeryDept.id, + month: d('2026-05-01'), + status: CampaignStatus.in_progress, + }, + }); + await prisma.evaluationForm.createMany({ + data: [ + { + campaignId: campaign.id, + employeeId: nurse.id, + abilitatiClinice: EvaluationScore.bine, + judecataClinica: EvaluationScore.bine, + manopere: EvaluationScore.bine, + gestionareaSarcinilor: EvaluationScore.mediu, + constiintaProfesionala: EvaluationScore.bine, + atitudineaPacienti: EvaluationScore.bine, + atitudineaColegi: EvaluationScore.bine, + atitudineaPersonalNonMed: EvaluationScore.mediu, + utilizareSmartphone: EvaluationScore.bine, + respectareaProgramului: EvaluationScore.bine, + respectareaDressCode: EvaluationScore.bine, + testJci: { score: 18, max_score: 20, percent: 90, source: 'academy_ocean_test' }, + completareaDocMed: true, + perfectioneazaCunostinte: true, + membruComitetCalitate: true, + functieDeMonitor: false, + inlocuiesteSuperiorul: false, + categorieCalculata: ProposedCategory.superioara, + }, + { + campaignId: campaign.id, + employeeId: surgeon.id, + abilitatiClinice: EvaluationScore.mediu, + judecataClinica: EvaluationScore.mediu, + manopere: EvaluationScore.bine, + categorieCalculata: ProposedCategory.fara, + }, + ], + }); + + const closedCampaign = await prisma.evaluationCampaign.create({ + data: { + name: 'Test evaluare nursing - Radiologie 2025', + departmentId: radiologyDept.id, + month: d('2025-11-01'), + status: CampaignStatus.closed, + }, + }); + await prisma.evaluationForm.create({ + data: { + campaignId: closedCampaign.id, + employeeId: radiologyNurse.id, + abilitatiClinice: EvaluationScore.bine, + judecataClinica: EvaluationScore.bine, + manopere: EvaluationScore.bine, + gestionareaSarcinilor: EvaluationScore.bine, + constiintaProfesionala: EvaluationScore.bine, + atitudineaPacienti: EvaluationScore.mediu, + atitudineaColegi: EvaluationScore.bine, + atitudineaPersonalNonMed: EvaluationScore.bine, + utilizareSmartphone: EvaluationScore.bine, + respectareaProgramului: EvaluationScore.bine, + respectareaDressCode: EvaluationScore.mediu, + completareaDocMed: true, + perfectioneazaCunostinte: true, + membruComitetCalitate: false, + functieDeMonitor: false, + inlocuiesteSuperiorul: false, + categorieCalculata: ProposedCategory.cat_I, + categorieAprobata: ProposedCategory.cat_I, + observatii: 'Formular închis pentru test read-only.', + completedAt: d('2025-11-20'), + }, + }); + + for (const type of [AnexaType.ANEXA_3, AnexaType.ANEXA_4, AnexaType.ANEXA_4A, AnexaType.ANEXA_4B, AnexaType.ANEXA_6]) { + await prisma.anexaTemplate.create({ + data: { + type, + name: `Test ${type}`, + contentJson: { source: 'templates/docx', note: 'DOCX template is stored on disk' }, + updatedById: 'seed-test', + }, + }); + } + + console.log('Seed summary:'); + console.log(` employees=${await prisma.employee.count()}`); + console.log(` workplaceRiskCards=${await prisma.workplaceRiskCard.count()}`); + console.log(` riskExposures=${await prisma.workplaceRiskExposure.count()}`); + console.log(` medicalCheckups=${await prisma.medicalCheckup.count()}`); + console.log(` evaluationForms=${await prisma.evaluationForm.count()}`); + console.log(` profiles=${profileByEmployeeId.size}`); +} + +main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/scripts/test-db.ts b/apps/api/scripts/test-db.ts new file mode 100644 index 0000000..e3b882f --- /dev/null +++ b/apps/api/scripts/test-db.ts @@ -0,0 +1,296 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawn, ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { PrismaClient } from '@prisma/client'; + +type CommandEnv = NodeJS.ProcessEnv; + +function loadEnvFile(filePath: string) { + if (!existsSync(filePath)) return; + const content = readFileSync(filePath, 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + if (process.env[key] !== undefined) continue; + process.env[key] = rawValue.trim().replace(/^['"]|['"]$/g, ''); + } +} + +function loadLocalEnv() { + loadEnvFile(resolve(process.cwd(), '.env')); + loadEnvFile(resolve(process.cwd(), '..', '..', '.env')); +} + +function timestamp() { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '_', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} + +function databaseNameFromUrl(url: string) { + return decodeURIComponent(new URL(url).pathname.replace(/^\//, '')); +} + +function databaseUrl(baseUrl: string, dbName: string) { + const url = new URL(baseUrl); + url.pathname = `/${dbName}`; + return url.toString(); +} + +function adminUrl(baseUrl: string) { + const url = new URL(baseUrl); + url.pathname = '/postgres'; + return url.toString(); +} + +function assertTestDatabaseName(dbName: string) { + if (!/^hrm_medpark_test_[A-Za-z0-9_]+$/.test(dbName)) { + throw new Error(`Refusing unsafe database name "${dbName}". Expected hrm_medpark_test_*.`); + } +} + +function quotedIdentifier(dbName: string) { + assertTestDatabaseName(dbName); + return `"${dbName.replace(/"/g, '""')}"`; +} + +function pnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +} + +function cleanEnv(env: CommandEnv) { + const cleaned: Record<string, string> = {}; + for (const [key, value] of Object.entries(env)) { + if (value === undefined || key.startsWith('=')) continue; + cleaned[key] = value; + } + return cleaned; +} + +async function createDatabase(baseUrl: string, dbName: string) { + assertTestDatabaseName(dbName); + const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } }); + try { + await admin.$executeRawUnsafe(`CREATE DATABASE ${quotedIdentifier(dbName)}`); + } finally { + await admin.$disconnect(); + } +} + +async function dropDatabase(baseUrl: string, dbName: string) { + assertTestDatabaseName(dbName); + const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } }); + try { + await admin.$executeRawUnsafe( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()', + dbName, + ); + await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${quotedIdentifier(dbName)}`); + } finally { + await admin.$disconnect(); + } +} + +async function runCommand(label: string, args: string[], env: CommandEnv) { + console.log(`\n> ${label}`); + await new Promise<void>((resolvePromise, reject) => { + const child = spawn(pnpmCommand(), args, { + cwd: process.cwd(), + env: cleanEnv(env), + stdio: 'inherit', + shell: process.platform === 'win32', + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}`)); + }); + }); +} + +async function findFreePort() { + return new Promise<number>((resolvePromise, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Unable to allocate a free API port')); + return; + } + const port = address.port; + server.close(() => resolvePromise(port)); + }); + }); +} + +async function waitForApi(baseUrl: string, child: ChildProcess) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 60_000) { + if (child.exitCode !== null) { + throw new Error(`Temporary API exited before becoming ready (exit code ${child.exitCode})`); + } + try { + const response = await fetch(`${baseUrl}/auth/dev-login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'readiness', role: 'hr_admin' }), + }); + if (response.status === 200 || response.status === 201) return; + } catch { + // API is still booting. + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000)); + } + throw new Error(`Temporary API did not become ready at ${baseUrl}`); +} + +async function startTemporaryApi(dbUrl: string, dbName: string) { + const port = Number(process.env.TEST_API_PORT ?? (await findFreePort())); + const baseUrl = `http://127.0.0.1:${port}/api/v1`; + const bucket = `hrm-docs-test-${dbName.replace(/_/g, '-')}`.slice(0, 63); + const env: CommandEnv = { + ...process.env, + DATABASE_URL: dbUrl, + PORT: String(port), + NODE_ENV: 'test', + ALLOW_DEV_LOGIN: 'true', + MINIO_BUCKET: bucket, + }; + + console.log(`\n> Starting temporary API on ${baseUrl}`); + const child = spawn(pnpmCommand(), ['exec', 'nest', 'start'], { + cwd: process.cwd(), + env: cleanEnv(env), + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }); + child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(`[api] ${chunk.toString()}`)); + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[api] ${chunk.toString()}`)); + await waitForApi(baseUrl, child); + return { child, baseUrl }; +} + +async function stopTemporaryApi(child: ChildProcess | null) { + if (!child || child.exitCode !== null) return; + const waitForExit = async () => { + if (child.exitCode !== null) return; + await new Promise<void>((resolvePromise) => { + const timeout = setTimeout(() => resolvePromise(), 10_000); + const done = () => { + clearTimeout(timeout); + resolvePromise(); + }; + child.once('exit', done); + child.once('close', done); + }); + }; + if (process.platform === 'win32' && child.pid) { + await new Promise<void>((resolvePromise) => { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + killer.on('exit', () => resolvePromise()); + killer.on('error', () => resolvePromise()); + }); + await waitForExit(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + return; + } + child.kill('SIGTERM'); + await waitForExit(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); +} + +async function run() { + loadLocalEnv(); + const baseUrl = process.env.DATABASE_URL; + if (!baseUrl) throw new Error('DATABASE_URL is required. Put it in apps/api/.env or export it before running testdb:run.'); + + const dbName = `hrm_medpark_test_${timestamp()}`; + const dbUrl = databaseUrl(baseUrl, dbName); + const commandEnv: CommandEnv = { ...process.env, DATABASE_URL: dbUrl }; + let api: Awaited<ReturnType<typeof startTemporaryApi>> | null = null; + + console.log(`Creating temporary database ${dbName}...`); + await createDatabase(baseUrl, dbName); + + try { + await runCommand('prisma migrate deploy', ['exec', 'prisma', 'migrate', 'deploy'], commandEnv); + await runCommand('seed test data', ['exec', 'ts-node', 'scripts/seed-test-data.ts'], commandEnv); + + api = await startTemporaryApi(dbUrl, dbName); + await runCommand('verify functionality', ['exec', 'ts-node', 'scripts/verify-functionality.ts'], { + ...commandEnv, + API_BASE_URL: api.baseUrl, + }); + + console.log('\nTemporary database is ready for manual checks.'); + console.log(`DATABASE_URL=${dbUrl}`); + console.log('\nRun API manually from the repo root with:'); + console.log(` $env:DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api dev`); + console.log('\nDrop it later with:'); + console.log(` $env:TEST_DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api testdb:drop`); + } catch (error) { + console.error('\nTest database run failed. The database was left in place for inspection:'); + console.error(`DATABASE_URL=${dbUrl}`); + throw error; + } finally { + await stopTemporaryApi(api?.child ?? null); + } +} + +function targetDatabaseFromArgs(baseUrl: string) { + const arg = process.argv.slice(3).find((value) => value !== '--') ?? process.env.TEST_DATABASE_URL ?? process.env.TEST_DATABASE_NAME; + if (!arg) { + throw new Error('Provide a test DB name or URL: pnpm.cmd --filter api testdb:drop -- hrm_medpark_test_YYYYMMDD_HHMMSS'); + } + if (/^postgres(?:ql)?:\/\//.test(arg)) { + return { dbName: databaseNameFromUrl(arg), baseUrl: arg }; + } + return { dbName: arg, baseUrl }; +} + +async function drop() { + loadLocalEnv(); + const baseUrl = process.env.DATABASE_URL; + if (!baseUrl) throw new Error('DATABASE_URL is required for admin connection'); + const target = targetDatabaseFromArgs(baseUrl); + assertTestDatabaseName(target.dbName); + console.log(`Dropping temporary database ${target.dbName}...`); + await dropDatabase(target.baseUrl, target.dbName); + console.log(`Dropped ${target.dbName}.`); +} + +async function main() { + const command = process.argv[2] ?? 'run'; + if (command === 'run') { + await run(); + return; + } + if (command === 'drop') { + await drop(); + return; + } + throw new Error(`Unknown command "${command}". Use "run" or "drop".`); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/api/scripts/verify-functionality.ts b/apps/api/scripts/verify-functionality.ts new file mode 100644 index 0000000..29186e5 --- /dev/null +++ b/apps/api/scripts/verify-functionality.ts @@ -0,0 +1,446 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ExcelJS = require('exceljs'); +import PizZip from 'pizzip'; +import { + AnexaType, + MedicalCheckupType, + MedicalVerdict, + PrismaClient, + RiskExposureType, +} from '@prisma/client'; +import { DocxTemplateService } from '../src/modules/medical/services/docx-template.service'; + +type HttpResult = { status: number; body: unknown; text: string }; + +const prisma = new PrismaClient(); +const warnings: string[] = []; + +function ok(message: string) { + console.log(`OK ${message}`); +} + +function warn(message: string) { + warnings.push(message); + console.warn(`WARN ${message}`); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function dbNameFromUrl(url: string): string { + return decodeURIComponent(new URL(url).pathname.replace(/^\//, '')); +} + +function requireTemporaryDatabase() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is required for verification'); + const dbName = dbNameFromUrl(url); + if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') { + throw new Error(`Refusing to verify non-test database "${dbName}". Expected hrm_medpark_test_*.`); + } + return dbName; +} + +function sourcePath(fileName: string) { + return resolve(process.cwd(), '..', '..', '..', fileName); +} + +function docxText(filePath: string): string { + const zip = new PizZip(readFileSync(filePath)); + const xml = Object.keys(zip.files) + .filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name)) + .map((name) => zip.file(name)?.asText() ?? '') + .join('\n'); + return xml + .replace(/<\/w:p>/g, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +async function verifySourceFiles() { + const controlDocx = process.env.CONTROL_MEDICAL_DOCX ?? sourcePath('Control medical (5).docx'); + const rubriciXlsx = process.env.RUBRICI_NECESARE_XLSX ?? sourcePath('Rubrici necesare (6).xlsx'); + + assert(existsSync(controlDocx), `Missing source file: ${controlDocx}`); + assert(existsSync(rubriciXlsx), `Missing source file: ${rubriciXlsx}`); + + const controlText = docxText(controlDocx); + for (const phrase of [ + /Baza de date a angajatilor trebuie sa contina/i, + /Persoana expusa profesional la radiatii ionizante/i, + /Tipul controlului medical/i, + /Fisa de solicitare/i, + /aptitudine/i, + /Apt condi/i, + /Inapt temporar/i, + ]) { + assert(phrase.test(controlText), `Control medical checklist phrase not found: ${phrase}`); + } + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(rubriciXlsx); + const rubrici = workbook.getWorksheet('Rubrici'); + const performance = workbook.getWorksheet('Sheet1'); + assert(rubrici, 'Rubrici necesare workbook must contain sheet "Rubrici"'); + assert(performance, 'Rubrici necesare workbook must contain sheet "Sheet1"'); + + const fields = new Set<string>(); + for (let rowIndex = 1; rowIndex <= rubrici.actualRowCount; rowIndex += 1) { + const value = excelCellText(rubrici.getRow(rowIndex).getCell(3)).trim(); + if (value) fields.add(value); + } + for (const field of ['IDNP', 'Nume', 'Prenume', 'Data nasterii', 'Domiciliu', 'Nr de telefon personal', 'tipul actului de identitate', 'Nr CIM']) { + assert(fields.has(field), `Rubrici checklist field not found: ${field}`); + } + + const performanceText = docxLikeSheetText(performance); + for (const phrase of ['Abilitati clinice nursing', 'Judecata clinica', 'Respectarea Dress Code', 'Membru unui comitet']) { + assert(performanceText.includes(phrase), `Performance checklist phrase not found: ${phrase}`); + } + + ok('source DOCX/XLSX checklists are present and readable'); +} + +function docxLikeSheetText(worksheet: ExcelJS.Worksheet) { + const parts: string[] = []; + worksheet.eachRow((row) => { + row.eachCell((cell) => { + const text = excelCellText(cell).trim(); + if (text) parts.push(text); + }); + }); + return parts.join(' '); +} + +function excelCellText(cell: ExcelJS.Cell) { + const value = cell.value; + if (value == null) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value !== 'object') return String(value); + if ('text' in value && typeof value.text === 'string') return value.text; + if ('result' in value && value.result != null) return String(value.result); + if ('richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join(''); + } + if ('hyperlink' in value && 'text' in value && typeof value.text === 'string') return value.text; + return ''; +} + +async function verifyDatabaseCoverage() { + const [ + employees, + identityDocuments, + familyMembers, + educations, + qualifications, + trainings, + sanctions, + benefits, + contracts, + riskCards, + profiles, + checkups, + evaluationForms, + ] = await Promise.all([ + prisma.employee.count(), + prisma.identityDocument.count(), + prisma.familyMember.count(), + prisma.education.count(), + prisma.qualification.count(), + prisma.training.count(), + prisma.disciplinarySanction.count(), + prisma.benefit.count(), + prisma.employmentContract.count(), + prisma.workplaceRiskCard.count(), + prisma.employeeMedicalProfile.count(), + prisma.medicalCheckup.count(), + prisma.evaluationForm.count(), + ]); + + assert(employees >= 6, `Expected at least 6 employees, got ${employees}`); + assert(identityDocuments >= employees, 'Every seeded employee should have an identity document'); + assert(familyMembers >= 3, 'Expected family/contact records'); + assert(educations >= employees, 'Expected education records for all employees'); + assert(qualifications >= 4, 'Expected qualification records'); + assert(trainings >= employees, 'Expected training records for all employees'); + assert(sanctions >= 2, 'Expected disciplinary sanction scenarios'); + assert(benefits >= 2, 'Expected benefit scenarios'); + assert(contracts >= employees, 'Expected active CIM for all employees'); + assert(riskCards >= 3, 'Expected STANDARD, radiation, and DISTANTA_DIGITAL risk cards'); + assert(profiles >= employees, 'Expected medical profile for all employees'); + assert(checkups >= 8, 'Expected pending and completed checkups'); + assert(evaluationForms >= 3, 'Expected performance evaluation forms'); + + const exposureTypes = await prisma.workplaceRiskExposure.findMany({ select: { tip: true }, distinct: ['tip'] }); + const seededExposureTypes = new Set(exposureTypes.map((row) => row.tip)); + for (const type of Object.values(RiskExposureType)) { + assert(seededExposureTypes.has(type), `Missing risk exposure type in test seed: ${type}`); + } + + const verdicts = await prisma.medicalCheckup.findMany({ + where: { verdict: { not: null } }, + select: { verdict: true }, + distinct: ['verdict'], + }); + const seededVerdicts = new Set(verdicts.map((row) => row.verdict)); + for (const verdict of Object.values(MedicalVerdict)) { + assert(seededVerdicts.has(verdict), `Missing medical verdict scenario: ${verdict}`); + } + + const radiationProfiles = await prisma.employeeMedicalProfile.count({ where: { expusRadiatiiIonizante: true } }); + const overexposures = await prisma.radiationOverexposure.findMany({ select: { fel: true }, distinct: ['fel'] }); + const remoteCards = await prisma.workplaceRiskCard.count({ where: { tipFisa: 'DISTANTA_DIGITAL' } }); + const pending = await prisma.medicalCheckup.count({ where: { verdict: null } }); + + assert(radiationProfiles >= 2, 'Expected at least two radiation-exposed employees'); + assert(overexposures.length >= 2, 'Expected exceptional and accidental overexposure rows'); + assert(remoteCards >= 1, 'Expected Anexa 4A/DISTANTA_DIGITAL risk card'); + assert(pending >= 3, 'Expected pending checkups for medic inbox'); + + ok('database seed covers HR, performance, and Control medical scenarios'); +} + +function extractTemplatePlaceholders(fileName: string) { + const fullPath = resolve(process.cwd(), 'templates', 'docx', fileName); + assert(existsSync(fullPath), `Missing DOCX template: ${fullPath}`); + const zip = new PizZip(readFileSync(fullPath)); + const xml = Object.keys(zip.files) + .filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name)) + .map((name) => zip.file(name)?.asText() ?? '') + .join('\n'); + const opens = (xml.match(/\{/g) ?? []).length; + const closes = (xml.match(/\}/g) ?? []).length; + assert(opens === closes, `${fileName} has unbalanced placeholders: opens=${opens}, closes=${closes}`); + const placeholders = Array.from(xml.matchAll(/\{([^{}]+)\}/g)).map((match) => match[1]); + return { placeholders, opens }; +} + +function valueForPlaceholder(name: string, index = 1) { + if (name.startsWith('cb')) return index % 2 === 0 ? '☐' : '☑'; + if (/data|PanaLa/i.test(name)) return '27.05.2026'; + if (/email/i.test(name)) return 'hr.test@medpark.md'; + if (/telefon|fax/i.test(name)) return '+373 22 000 000'; + if (/idnp/i.test(name)) return `19850615000${index}`; + if (/nr|numar|anNastere|ani/i.test(name)) return String(index); + if (/doza/i.test(name)) return (index + 0.25).toFixed(4); + return `Test ${name} ${index}`; +} + +function sampleDocxData(placeholders: string[]) { + const data: Record<string, unknown> = {}; + for (const raw of placeholders) { + const name = raw.replace(/^#|\//g, ''); + if (!name || raw.startsWith('#') || raw.startsWith('/')) continue; + data[name] = valueForPlaceholder(name); + } + const row = (index: number) => { + const values: Record<string, string> = {}; + for (const raw of placeholders) { + const name = raw.replace(/^#|\//g, ''); + if (!name || raw.startsWith('#') || raw.startsWith('/')) continue; + values[name] = valueForPlaceholder(name, index); + } + values.nr = String(index); + values.numePrenume = index === 1 ? 'Popescu Alexandru' : 'Ionescu Maria'; + values.denumire = index === 1 ? 'Glutaraldehidă' : 'HBV/HCV/HIV'; + values.tipExpunere = index === 1 ? 'X externă' : 'gamma externă'; + return values; + }; + for (const loopName of ['angajati', 'chimici', 'pulberi', 'biologici', 'zgomot', 'vibratii', 'campEM', 'optice', 'supraexpExceptionale', 'supraexpAccidentale']) { + data[loopName] = [row(1), row(2)]; + } + return data; +} + +async function verifyDocxTemplates() { + const service = new DocxTemplateService(); + const files: Record<AnexaType, string> = { + ANEXA_3: 'anexa-3.docx', + ANEXA_4: 'anexa-4.docx', + ANEXA_4A: 'anexa-4a.docx', + ANEXA_4B: 'anexa-4b.docx', + ANEXA_6: 'anexa-6.docx', + }; + + for (const [type, fileName] of Object.entries(files) as [AnexaType, string][]) { + const { placeholders, opens } = extractTemplatePlaceholders(fileName); + assert(opens > 0, `${fileName} should contain docxtemplater placeholders`); + const rendered = service.render(type, sampleDocxData(placeholders)); + const zip = new PizZip(rendered); + const badParts: string[] = []; + for (const name of Object.keys(zip.files).filter((entry) => /^word\/(document|header|footer).*\.xml$/.test(entry))) { + const xml = zip.file(name)?.asText() ?? ''; + if (/[{}]/.test(xml) || /\b(undefined|null)\b/i.test(xml)) badParts.push(name); + } + assert(badParts.length === 0, `${fileName} rendered XML still contains placeholders/nulls in ${badParts.join(', ')}`); + } + + ok('all Anexa DOCX templates render cleanly through DocxTemplateService'); +} + +async function isMinioAvailable() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + try { + const response = await fetch('http://localhost:9000/minio/health/live', { signal: controller.signal }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +async function apiRequest(baseUrl: string, method: string, path: string, body?: unknown, token?: string): Promise<HttpResult> { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + const headers: Record<string, string> = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + let parsed: unknown = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { status: response.status, body: parsed, text }; + } catch (error) { + lastError = error; + if (attempt < 3) await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000 * attempt)); + } finally { + clearTimeout(timeout); + } + } + throw new Error(`${method} ${path} failed after retries: ${String(lastError)}`); +} + +function tokenFrom(body: unknown) { + if (typeof body === 'object' && body && 'token' in body && typeof (body as { token: unknown }).token === 'string') { + return (body as { token: string }).token; + } + throw new Error('dev-login response did not include token'); +} + +async function verifyHttpSmoke(minioAvailable: boolean) { + const baseUrl = process.env.API_BASE_URL; + if (!baseUrl) { + warn('API_BASE_URL is not set; skipped HTTP smoke tests. testdb:run starts a temporary API automatically.'); + return; + } + + const adminLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-admin', role: 'hr_admin' }); + const specialistLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-specialist', role: 'hr_specialist' }); + const medicLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-medic', role: 'medic_familie' }); + assert(adminLogin.status === 201 || adminLogin.status === 200, `hr_admin dev-login failed: ${adminLogin.text}`); + assert(specialistLogin.status === 201 || specialistLogin.status === 200, `hr_specialist dev-login failed: ${specialistLogin.text}`); + assert(medicLogin.status === 201 || medicLogin.status === 200, `medic_familie dev-login failed: ${medicLogin.text}`); + const adminToken = tokenFrom(adminLogin.body); + const specialistToken = tokenFrom(specialistLogin.body); + const medicToken = tokenFrom(medicLogin.body); + + for (const path of ['/dashboard/stats', '/medical/risk-cards', '/medical/upcoming-expirations']) { + const response = await apiRequest(baseUrl, 'GET', path, undefined, adminToken); + assert(response.status >= 200 && response.status < 300, `GET ${path} failed: ${response.status} ${response.text}`); + } + + const employees = await prisma.employee.findMany({ + where: { status: 'activ', medicalProfile: { workplaceRiskCardId: { not: null } } }, + select: { id: true }, + take: 5, + }); + assert(employees.length >= 5, 'HTTP bulk smoke needs at least five eligible employees'); + const bulkBody = { + employeeIds: employees.map((employee) => employee.id), + tip: MedicalCheckupType.periodic, + dataPlanificata: '2026-06-15', + documentContext: { + telefon: '+373 22 000 000', + fax: '+373 22 000 001', + email: 'hr.test@medpark.md', + solicitant: 'Test HR Admin', + functia: 'Specialist resurse umane', + }, + }; + + const forbidden = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, specialistToken); + assert(forbidden.status === 403, `hr_specialist bulk initiate should be 403, got ${forbidden.status}: ${forbidden.text}`); + + if (!minioAvailable) { + warn('MinIO is not reachable on localhost:9000; skipped full upload-dependent bulk success and medic completion HTTP tests.'); + ok('HTTP smoke passed for auth, read endpoints, and role-based 403'); + return; + } + + const bulk = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, adminToken); + assert(bulk.status >= 200 && bulk.status < 300, `hr_admin bulk initiate failed: ${bulk.status} ${bulk.text}`); + assert(typeof bulk.body === 'object' && bulk.body && 'groupsCount' in bulk.body, 'bulk response should include groupsCount'); + + const pending = await prisma.medicalCheckup.findFirst({ + where: { verdict: null }, + orderBy: { createdAt: 'asc' }, + }); + assert(pending, 'No pending checkup found for medic completion smoke'); + const complete = await apiRequest( + baseUrl, + 'PATCH', + `/medical/checkups/${pending.id}/complete`, + { + verdict: MedicalVerdict.apt_conditionat, + dataEfectuata: '2026-06-16', + recomandari: 'Test: lucru cu dozimetru și reevaluare anuală.', + valabilPanaLa: '2027-06-16', + semnatDe: 'Dr. Verificare Test', + }, + medicToken, + ); + assert(complete.status >= 200 && complete.status < 300, `medic completion failed: ${complete.status} ${complete.text}`); + + const completed = await prisma.medicalCheckup.findUnique({ where: { id: pending.id } }); + assert(completed?.verdict === MedicalVerdict.apt_conditionat, 'Medic completion did not persist verdict'); + assert(completed.semnatDe === 'Dr. Verificare Test', 'Medic completion did not persist semnatDe'); + assert(Array.isArray(completed.documenteGenerate), 'Completed checkup should contain generated documents'); + assert(JSON.stringify(completed.documenteGenerate).includes('Anexa_6_Final'), 'Completed checkup should include final Anexa 6 document'); + + ok('HTTP smoke passed, including MinIO-backed document generation'); +} + +async function main() { + const dbName = requireTemporaryDatabase(); + console.log(`Verifying Medpark test database ${dbName}...`); + await verifySourceFiles(); + await verifyDatabaseCoverage(); + await verifyDocxTemplates(); + const minioAvailable = await isMinioAvailable(); + if (minioAvailable) ok('MinIO is reachable on localhost:9000'); + else warn('MinIO is not reachable on localhost:9000; upload-dependent checks will be skipped or marked failed.'); + await verifyHttpSmoke(minioAvailable); + + if (warnings.length > 0) { + console.log('\nVerification completed with warnings:'); + for (const message of warnings) console.log(`- ${message}`); + } else { + console.log('\nVerification completed without warnings.'); + } +} + +main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..29f244a --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,58 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { BullModule } from '@nestjs/bull'; +import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n'; +import * as path from 'path'; +import { PrismaModule } from './common/prisma/prisma.module'; +import { AuditModule } from './common/audit/audit.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { EmployeesModule } from './modules/employees/employees.module'; +import { DepartmentsModule } from './modules/departments/departments.module'; +import { ReferenceModule } from './modules/reference/reference.module'; +import { EvaluationModule } from './modules/evaluation/evaluation.module'; +import { MedicalModule } from './modules/medical/medical.module'; +import { DashboardModule } from './modules/dashboard/dashboard.module'; +import { ContractsGlobalModule } from './modules/contracts/contracts-global.module'; +import { AdminModule } from './modules/admin/admin.module'; +import { InventoryModule } from './modules/inventory/inventory.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), + + BullModule.forRoot({ + redis: { + host: process.env.REDIS_HOST ?? 'localhost', + port: Number(process.env.REDIS_PORT ?? 6379), + }, + }), + + I18nModule.forRoot({ + fallbackLanguage: 'ro', + loaderOptions: { + path: path.join(process.cwd(), 'i18n'), + watch: true, + }, + resolvers: [AcceptLanguageResolver], + }), + + PrismaModule, + AuditModule, + AuthModule, + EmployeesModule, + DepartmentsModule, + ReferenceModule, + EvaluationModule, + MedicalModule, + DashboardModule, + ContractsGlobalModule, + AdminModule, + InventoryModule, + NotificationsModule, + ], +}) +export class AppModule {} diff --git a/apps/api/src/common/audit/audit.decorator.ts b/apps/api/src/common/audit/audit.decorator.ts new file mode 100644 index 0000000..80d63f3 --- /dev/null +++ b/apps/api/src/common/audit/audit.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; +import { AuditAction } from './audit.service'; + +export const AUDIT_META_KEY = 'audit_meta'; + +export interface AuditMeta { + action: AuditAction; + entity: string; +} + +export const Audit = (action: AuditAction, entity: string) => + SetMetadata(AUDIT_META_KEY, { action, entity } satisfies AuditMeta); diff --git a/apps/api/src/common/audit/audit.interceptor.ts b/apps/api/src/common/audit/audit.interceptor.ts new file mode 100644 index 0000000..aa8869b --- /dev/null +++ b/apps/api/src/common/audit/audit.interceptor.ts @@ -0,0 +1,43 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, tap } from 'rxjs'; +import { AuditService } from './audit.service'; +import { AUDIT_META_KEY, AuditMeta } from './audit.decorator'; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + private readonly auditService: AuditService, + private readonly reflector: Reflector, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { + const meta = this.reflector.get<AuditMeta>( + AUDIT_META_KEY, + context.getHandler(), + ); + if (!meta) return next.handle(); + + const req = context.switchToHttp().getRequest(); + const user = req.user as { id: string; role: string } | undefined; + + return next.handle().pipe( + tap(() => { + if (!user) return; + void this.auditService.log({ + userId: user.id, + userRole: user.role, + ip: req.ip, + action: meta.action, + entity: meta.entity, + entityId: req.params?.id ?? 'bulk', + }); + }), + ); + } +} diff --git a/apps/api/src/common/audit/audit.module.ts b/apps/api/src/common/audit/audit.module.ts new file mode 100644 index 0000000..d1bf72c --- /dev/null +++ b/apps/api/src/common/audit/audit.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditService } from './audit.service'; +import { AuditInterceptor } from './audit.interceptor'; + +@Global() +@Module({ + providers: [AuditService, AuditInterceptor], + exports: [AuditService, AuditInterceptor], +}) +export class AuditModule {} diff --git a/apps/api/src/common/audit/audit.service.ts b/apps/api/src/common/audit/audit.service.ts new file mode 100644 index 0000000..c1dab94 --- /dev/null +++ b/apps/api/src/common/audit/audit.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type AuditAction = 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'EXPORT'; + +export interface AuditParams { + userId: string; + userRole: string; + ip?: string; + action: AuditAction; + entity: string; + entityId: string; + field?: string; + oldValue?: string; + newValue?: string; + reason?: string; +} + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + async log(params: AuditParams): Promise<void> { + await this.prisma.auditLog.create({ data: params }); + } + + async logRead(params: Omit<AuditParams, 'action'>): Promise<void> { + await this.log({ ...params, action: 'READ' }); + } + + async logChange( + params: Omit<AuditParams, 'action'> & { + action: 'CREATE' | 'UPDATE' | 'DELETE'; + }, + ): Promise<void> { + await this.log(params); + } +} diff --git a/apps/api/src/common/decorators/roles.decorator.ts b/apps/api/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..23ca852 --- /dev/null +++ b/apps/api/src/common/decorators/roles.decorator.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export type AppRole = + | 'hr_admin' + | 'hr_specialist' + | 'nursing_director' + | 'quality_auditor' + | 'manager' + | 'medic_familie' + | 'employee'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: AppRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..8178be8 --- /dev/null +++ b/apps/api/src/common/guards/roles.guard.ts @@ -0,0 +1,19 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AppRole, ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride<AppRole[]>(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required?.length) return true; + + const { user } = context.switchToHttp().getRequest<{ user: { role: AppRole } }>(); + return required.includes(user?.role); + } +} diff --git a/apps/api/src/common/prisma/prisma.module.ts b/apps/api/src/common/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/common/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/common/prisma/prisma.service.ts b/apps/api/src/common/prisma/prisma.service.ts new file mode 100644 index 0000000..7ffd32d --- /dev/null +++ b/apps/api/src/common/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..3de65d9 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,37 @@ +import { NestFactory } from '@nestjs/core'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create<NestFastifyApplication>( + AppModule, + new FastifyAdapter({ logger: true }), + ); + + app.setGlobalPrefix('api/v1'); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + app.enableCors({ + origin: process.env.FRONTEND_URL + ? process.env.FRONTEND_URL + : /^http:\/\/localhost(:\d+)?$/, + credentials: true, + }); + + const port = process.env.PORT ?? 3001; + await app.listen(port, '0.0.0.0'); + console.log(`HRM API running on http://localhost:${port}/api/v1`); +} + +bootstrap(); diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts new file mode 100644 index 0000000..9148ad9 --- /dev/null +++ b/apps/api/src/modules/admin/admin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AnexaTemplatesController } from './anexa-templates/anexa-templates.controller'; +import { AnexaTemplatesService } from './anexa-templates/anexa-templates.service'; + +@Module({ + controllers: [AnexaTemplatesController], + providers: [AnexaTemplatesService], +}) +export class AdminModule {} diff --git a/apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts b/apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts new file mode 100644 index 0000000..cf7a67f --- /dev/null +++ b/apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, Get, Put, Post, Body, Param, UseGuards, Request, HttpCode, HttpStatus, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AnexaType } from '@prisma/client'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { AnexaTemplatesService } from './anexa-templates.service'; +import { UpdateTemplateDto } from './dto/update-template.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('admin/anexa-templates') +@UseGuards(AuthGuard('jwt'), RolesGuard) +@Roles('hr_admin') +export class AnexaTemplatesController { + constructor(private readonly svc: AnexaTemplatesService) {} + + // Literal routes BEFORE :type to avoid routing conflicts in Fastify + @Get('preview-employee') + getPreviewEmployee() { + return this.svc.getPreviewEmployee(); + } + + @Get() + list() { + return this.svc.list(); + } + + @Get(':type') + findOne(@Param('type') type: AnexaType) { + return this.svc.findOne(type); + } + + @Put(':type') + update(@Param('type') type: AnexaType, @Body() dto: UpdateTemplateDto, @Request() req: AuthReq) { + return this.svc.update(type, dto, req.user.id, req.user.role); + } + + @Get(':type/versions') + getVersions(@Param('type') type: AnexaType) { + return this.svc.getVersions(type); + } + + @Post(':type/restore/:versionId') + @HttpCode(HttpStatus.OK) + restore( + @Param('type') type: AnexaType, + @Param('versionId') versionId: string, + @Request() req: AuthReq, + ) { + return this.svc.restore(type, versionId, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts b/apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts new file mode 100644 index 0000000..855350b --- /dev/null +++ b/apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts @@ -0,0 +1,116 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AnexaType } from '@prisma/client'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { UpdateTemplateDto } from './dto/update-template.dto'; + +const ANEXA_NAMES: Record<AnexaType, string> = { + ANEXA_3: 'Fișa de solicitare a examenului medical', + ANEXA_4: 'Fișa de evaluare a riscurilor profesionale', + ANEXA_4A: 'Fișa de evaluare — muncă la distanță/platforme digitale', + ANEXA_4B: 'Supliment radiații ionizante', + ANEXA_6: 'Verdict medic de familie', +}; + +@Injectable() +export class AnexaTemplatesService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + list() { + return this.prisma.anexaTemplate.findMany({ + select: { id: true, type: true, name: true, updatedById: true, updatedAt: true }, + orderBy: { type: 'asc' }, + }); + } + + async findOne(type: AnexaType) { + const t = await this.prisma.anexaTemplate.findUnique({ where: { type } }); + if (!t) throw new NotFoundException(`Template ${type} nu există`); + return t; + } + + async update(type: AnexaType, dto: UpdateTemplateDto, userId: string, role: string) { + const existing = await this.prisma.anexaTemplate.findUnique({ where: { type } }); + + if (existing) { + await this.prisma.anexaTemplateVersion.create({ + data: { + templateId: existing.id, + contentJson: existing.contentJson as never, + savedById: userId, + }, + }); + } + + const template = await this.prisma.anexaTemplate.upsert({ + where: { type }, + update: { + contentJson: dto.contentJson as never, + updatedById: userId, + ...(dto.name ? { name: dto.name } : {}), + }, + create: { + type, + name: dto.name ?? ANEXA_NAMES[type], + contentJson: dto.contentJson as never, + updatedById: userId, + }, + }); + + await this.audit.logChange({ + userId, + userRole: role, + action: 'UPDATE', + entity: 'AnexaTemplate', + entityId: template.id, + }); + return template; + } + + getVersions(type: AnexaType) { + return this.prisma.anexaTemplateVersion.findMany({ + where: { template: { type } }, + orderBy: { savedAt: 'desc' }, + take: 50, + }); + } + + async restore(type: AnexaType, versionId: string, userId: string, role: string) { + const version = await this.prisma.anexaTemplateVersion.findUniqueOrThrow({ + where: { id: versionId }, + }); + return this.update(type, { contentJson: version.contentJson }, userId, role); + } + + getPreviewEmployee() { + return this.prisma.employee.findFirst({ + select: { + id: true, + idnp: true, + nume: true, + prenume: true, + dataNasterii: true, + contracts: { + select: { + functiaOrganigrama: true, + functiaClasificator: true, + department: { select: { name: true } }, + }, + orderBy: { dataAngajarii: 'desc' }, + take: 1, + }, + medicalProfile: { + select: { + ocupatieCorm: true, + dozaCumulataExternaMsv: true, + dozaCumulataInternaMsv: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts b/apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts new file mode 100644 index 0000000..9403a44 --- /dev/null +++ b/apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateTemplateDto { + @IsOptional() + contentJson?: unknown; + + @IsOptional() + @IsString() + name?: string; +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..3796eb8 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Post, Body, Get, UseGuards, Request, ForbiddenException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from '@nestjs/passport'; + +const DEV_SECRET = process.env.DEV_JWT_SECRET ?? 'dev-secret-hrm-2026'; + +const VALID_ROLES = [ + 'hr_admin', 'hr_specialist', 'nursing_director', + 'quality_auditor', 'manager', 'medic_familie', 'employee', +]; + +@Controller('auth') +export class AuthController { + constructor(private readonly jwt: JwtService) {} + + /** + * Local dev login — generates HS256 token. + * Blocked in production via NODE_ENV check. + * Safe in production: when KEYCLOAK_URL is set, the JWT strategy uses RS256/JWKS + * and will reject any HS256 token automatically. + */ + @Post('dev-login') + devLogin(@Body() body: { username?: string; role?: string }) { + if (process.env.NODE_ENV === 'production' && process.env.ALLOW_DEV_LOGIN !== 'true') { + throw new ForbiddenException('Dev-login este dezactivat în producție'); + } + const username = (body.username ?? 'admin').trim() || 'admin'; + const role = VALID_ROLES.includes(body.role ?? '') ? body.role! : 'hr_admin'; + + const payload = { + sub: `dev-${role}`, + preferred_username: username, + realm_access: { roles: [role] }, + }; + + const token = this.jwt.sign(payload, { + secret: DEV_SECRET, + expiresIn: '8h', + }); + + return { token, username, role }; + } + + /** Returns current user info from JWT — useful for the frontend */ + @Get('me') + @UseGuards(AuthGuard('jwt')) + me(@Request() req: { user: { id: string; username: string; role: string } }) { + return req.user; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..0997a48 --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { KeycloakStrategy } from './keycloak.strategy'; +import { AuthController } from './auth.controller'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({}), + ], + controllers: [AuthController], + providers: [KeycloakStrategy], + exports: [PassportModule], +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/keycloak.strategy.ts b/apps/api/src/modules/auth/keycloak.strategy.ts new file mode 100644 index 0000000..c553c08 --- /dev/null +++ b/apps/api/src/modules/auth/keycloak.strategy.ts @@ -0,0 +1,93 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { passportJwtSecret } from 'jwks-rsa'; +import { AppRole } from '../../common/decorators/roles.decorator'; + +interface KeycloakToken { + sub: string; + preferred_username: string; + email?: string; + realm_access?: { roles: string[] }; + resource_access?: Record<string, { roles: string[] }>; +} + +type JwksSecretCallback = ( + request: unknown, + rawJwtToken: string, + done: (err: Error | null, secret?: string | Buffer) => void, +) => void; + +const DEV_SECRET = process.env.DEV_JWT_SECRET ?? 'dev-secret-hrm-2026'; + +// Lazy-initialised JWKS provider (only when KEYCLOAK_URL is set) +let jwksProvider: JwksSecretCallback | null = null; +function getJwksProvider(): JwksSecretCallback { + if (!jwksProvider) { + jwksProvider = passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`, + }) as unknown as JwksSecretCallback; + } + return jwksProvider; +} + +function parseTokenHeader(raw: string): Record<string, unknown> { + try { + return JSON.parse(Buffer.from(raw.split('.')[0], 'base64url').toString('utf8')); + } catch { + return {}; + } +} + +@Injectable() +export class KeycloakStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + // Accept both HS256 (dev tokens) and RS256 (Keycloak tokens) + algorithms: ['HS256', 'RS256'], + secretOrKeyProvider: ( + request: unknown, + rawToken: string, + done: (err: Error | null, secret?: string | Buffer) => void, + ) => { + const header = parseTokenHeader(rawToken); + if (header['alg'] === 'HS256') { + // Dev-login token — validate with local secret + done(null, DEV_SECRET); + return; + } + // Keycloak RS256 token — validate via JWKS + if (!process.env.KEYCLOAK_URL) { + done(new UnauthorizedException('Keycloak not configured for RS256')); + return; + } + getJwksProvider()(request, rawToken, done); + }, + }); + } + + validate(payload: KeycloakToken) { + const realmRoles = payload.realm_access?.roles ?? []; + const clientRoles = + payload.resource_access?.[process.env.KEYCLOAK_CLIENT_ID ?? 'hrm-api'] + ?.roles ?? []; + + const hrm_roles: AppRole[] = [ + 'hr_admin', 'hr_specialist', 'nursing_director', + 'quality_auditor', 'manager', 'medic_familie', 'employee', + ]; + + const role = [...realmRoles, ...clientRoles].find((r) => + hrm_roles.includes(r as AppRole), + ) as AppRole | undefined; + + if (!role) throw new UnauthorizedException('No HRM role assigned'); + + return { id: payload.sub, username: payload.preferred_username, role }; + } +} diff --git a/apps/api/src/modules/contracts/contracts-global.controller.ts b/apps/api/src/modules/contracts/contracts-global.controller.ts new file mode 100644 index 0000000..4f6b1f8 --- /dev/null +++ b/apps/api/src/modules/contracts/contracts-global.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { ContractsGlobalService, ContractStatus } from './contracts-global.service'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +interface ContractsQuery { + page?: string; + limit?: string; + departmentId?: string; + perioada?: string; + status?: ContractStatus; + search?: string; +} + +@Controller('contracts') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class ContractsGlobalController { + constructor(private readonly svc: ContractsGlobalService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist') + findAll(@Query() q: ContractsQuery, @Request() req: AuthReq) { + return this.svc.findAll( + { + page: q.page ? Number(q.page) : 1, + limit: q.limit ? Number(q.limit) : 50, + departmentId: q.departmentId, + perioada: q.perioada, + status: q.status, + search: q.search, + }, + req.user.id, + req.user.role, + ); + } +} diff --git a/apps/api/src/modules/contracts/contracts-global.module.ts b/apps/api/src/modules/contracts/contracts-global.module.ts new file mode 100644 index 0000000..dd81492 --- /dev/null +++ b/apps/api/src/modules/contracts/contracts-global.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ContractsGlobalController } from './contracts-global.controller'; +import { ContractsGlobalService } from './contracts-global.service'; + +@Module({ + controllers: [ContractsGlobalController], + providers: [ContractsGlobalService], +}) +export class ContractsGlobalModule {} diff --git a/apps/api/src/modules/contracts/contracts-global.service.ts b/apps/api/src/modules/contracts/contracts-global.service.ts new file mode 100644 index 0000000..5c0a0b7 --- /dev/null +++ b/apps/api/src/modules/contracts/contracts-global.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, ContractPeriod } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { AuditService } from '../../common/audit/audit.service'; + +export type ContractStatus = 'activ' | 'expirat' | 'expira_in_curand'; + +interface ListQuery { + page: number; + limit: number; + departmentId?: string; + perioada?: string; + status?: ContractStatus; + search?: string; +} + +@Injectable() +export class ContractsGlobalService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async findAll(query: ListQuery, userId: string, role: string) { + const { page, limit, departmentId, perioada, status, search } = query; + + // Issue 4: cap limit at 200 + const safLimit = Math.min(limit, 200); + + const now = new Date(); + const in30Days = new Date(now.getTime() + 30 * 86_400_000); + + // Issue 1: use Prisma.EmploymentContractWhereInput instead of Record<string, unknown> + const where: Prisma.EmploymentContractWhereInput = {}; + if (departmentId) where.departmentId = departmentId; + if (perioada) where.perioada = perioada as ContractPeriod; + if (search) { + where.employee = { + OR: [ + { nume: { contains: search, mode: 'insensitive' } }, + { prenume: { contains: search, mode: 'insensitive' } }, + ], + }; + } + + // Issue 2: push status filter to DB instead of in-memory full-table scan + if (status === 'expirat') { + where.OR = [ + { dataDemisiei: { lt: now } }, + { AND: [{ perioada: ContractPeriod.determinata }, { dataTerminarii: { lt: now } }] }, + ]; + } else if (status === 'expira_in_curand') { + where.AND = [ + { dataDemisiei: null }, + { perioada: ContractPeriod.determinata }, + { dataTerminarii: { gte: now, lte: in30Days } }, + ]; + } else if (status === 'activ') { + where.dataDemisiei = null; + where.NOT = { + OR: [ + { dataTerminarii: { lt: now } }, + { AND: [{ dataTerminarii: { gte: now, lte: in30Days } }, { perioada: ContractPeriod.determinata }] }, + ], + }; + } + + const include = { + employee: { select: { id: true, idnp: true, nume: true, prenume: true } }, + department: { select: { id: true, name: true } }, + workSchedule: { select: { id: true, name: true } }, + categoriiServicii: true, + } satisfies Prisma.EmploymentContractInclude; + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.employmentContract.count({ where }), + this.prisma.employmentContract.findMany({ + where, + include, + orderBy: { dataAngajarii: 'desc' }, + skip: (page - 1) * safLimit, + take: safLimit, + }), + ]); + + const items = rows.map((c) => ({ ...c, status: this.computeStatus(c, now) })); + + await this.audit.logRead({ userId, userRole: role, entity: 'EmploymentContract', entityId: 'GLOBAL_LIST' }); + return { total, page, limit: safLimit, items }; + } + + private computeStatus( + c: { dataDemisiei: Date | null; perioada: ContractPeriod; dataTerminarii: Date | null }, + now: Date, + ): ContractStatus { + // Issue 3: use <= instead of < + if (c.dataDemisiei && c.dataDemisiei <= now) return 'expirat'; + if (c.perioada === ContractPeriod.determinata && c.dataTerminarii) { + const daysLeft = Math.floor((c.dataTerminarii.getTime() - now.getTime()) / 86_400_000); + if (daysLeft < 0) return 'expirat'; + if (daysLeft <= 30) return 'expira_in_curand'; + } + return 'activ'; + } +} diff --git a/apps/api/src/modules/dashboard/dashboard.controller.ts b/apps/api/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..c8c2763 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { DashboardService } from './dashboard.service'; + +@Controller('dashboard') +@UseGuards(AuthGuard('jwt')) +export class DashboardController { + constructor(private readonly svc: DashboardService) {} + + @Get('stats') + getStats() { + return this.svc.getStats(); + } +} diff --git a/apps/api/src/modules/dashboard/dashboard.module.ts b/apps/api/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/apps/api/src/modules/dashboard/dashboard.service.ts b/apps/api/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..bf1783c --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.service'; + +@Injectable() +export class DashboardService { + constructor(private readonly prisma: PrismaService) {} + + async getStats() { + const today = new Date(); + const in30 = new Date(today); in30.setDate(today.getDate() + 30); + const in60 = new Date(today); in60.setDate(today.getDate() + 60); + const in90 = new Date(today); in90.setDate(today.getDate() + 90); + const ago30 = new Date(today); ago30.setDate(today.getDate() - 30); + + const [ + employeesByStatus, + activeContracts, + contractsDeterminata, + recentHires, + activeSanctions, + expiringDocs, + upcomingCheckups, + expiringQualifications, + ] = await Promise.all([ + // Employee counts by status + this.prisma.employee.groupBy({ + by: ['status'], + _count: { _all: true }, + }), + + // Active contracts (no dataDemisiei) + this.prisma.employmentContract.count({ + where: { dataDemisiei: null }, + }), + + // Determinata contracts expiring in 30 days + this.prisma.employmentContract.findMany({ + where: { + dataDemisiei: null, + perioada: 'determinata', + dataTerminarii: { gte: today, lte: in30 }, + }, + select: { + id: true, + nrCim: true, + dataTerminarii: true, + employee: { select: { id: true, nume: true, prenume: true, idnp: true } }, + department: { select: { name: true } }, + }, + orderBy: { dataTerminarii: 'asc' }, + take: 20, + }), + + // Recent hires (last 30 days) + this.prisma.employmentContract.count({ + where: { + dataAngajarii: { gte: ago30 }, + dataDemisiei: null, + }, + }), + + // Active (non-stinsa) disciplinary sanctions + this.prisma.disciplinarySanction.count({ + where: { isStinsa: false }, + }), + + // Identity documents expiring in 30 days + this.prisma.identityDocument.findMany({ + where: { + dataExpirarii: { gte: today, lte: in30 }, + }, + select: { + id: true, + tipAct: true, + dataExpirarii: true, + employee: { select: { id: true, nume: true, prenume: true, idnp: true } }, + }, + orderBy: { dataExpirarii: 'asc' }, + take: 20, + }), + + // Medical checkups due in 60 days (dataPlanificata, not yet effectuated) + this.prisma.medicalCheckup.findMany({ + where: { + dataEfectuata: null, + dataPlanificata: { gte: today, lte: in60 }, + }, + select: { + id: true, + tip: true, + dataPlanificata: true, + employee: { select: { id: true, nume: true, prenume: true, idnp: true } }, + }, + orderBy: { dataPlanificata: 'asc' }, + take: 20, + }), + + // Qualifications expiring in 90 days + this.prisma.qualification.findMany({ + where: { + dataExpirarii: { gte: today, lte: in90 }, + }, + select: { + id: true, + categorie: true, + dataExpirarii: true, + employee: { select: { id: true, nume: true, prenume: true, idnp: true } }, + }, + orderBy: { dataExpirarii: 'asc' }, + take: 20, + }), + ]); + + const statusMap = Object.fromEntries( + employeesByStatus.map((r) => [r.status, r._count._all]), + ); + + return { + employees: { + total: Object.values(statusMap).reduce((a, b) => a + b, 0), + activ: statusMap['activ'] ?? 0, + concediat: statusMap['concediat'] ?? 0, + suspendat: statusMap['suspendat'] ?? 0, + }, + activeContracts, + recentHires, + activeSanctions, + expirations: { + contractsDeterminata, + expiringDocs, + upcomingCheckups, + expiringQualifications, + }, + }; + } +} diff --git a/apps/api/src/modules/departments/departments.controller.ts b/apps/api/src/modules/departments/departments.controller.ts new file mode 100644 index 0000000..590aca6 --- /dev/null +++ b/apps/api/src/modules/departments/departments.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { DepartmentsService } from './departments.service'; + +@Controller('departments') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class DepartmentsController { + constructor(private readonly svc: DepartmentsService) {} + + @Get() + findAll() { + return this.svc.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.svc.findOne(id); + } + + @Post() + @Roles('hr_admin') + create(@Body() body: { name: string; code?: string; parentId?: string }) { + return this.svc.create(body); + } + + @Patch(':id') + @Roles('hr_admin') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { parentId?: string | null; name?: string }, + ) { + if (body.name !== undefined) return this.svc.rename(id, body.name); + return this.svc.move(id, body.parentId ?? null); + } + + @Delete(':id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.svc.delete(id); + } +} diff --git a/apps/api/src/modules/departments/departments.module.ts b/apps/api/src/modules/departments/departments.module.ts new file mode 100644 index 0000000..eaf0f61 --- /dev/null +++ b/apps/api/src/modules/departments/departments.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DepartmentsController } from './departments.controller'; +import { DepartmentsService } from './departments.service'; + +@Module({ + controllers: [DepartmentsController], + providers: [DepartmentsService], +}) +export class DepartmentsModule {} diff --git a/apps/api/src/modules/departments/departments.service.ts b/apps/api/src/modules/departments/departments.service.ts new file mode 100644 index 0000000..66002b2 --- /dev/null +++ b/apps/api/src/modules/departments/departments.service.ts @@ -0,0 +1,81 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.service'; + +@Injectable() +export class DepartmentsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + const all = await this.prisma.department.findMany({ + orderBy: { name: 'asc' }, + }); + + const map = new Map<string, any>(); + all.forEach(d => map.set(d.id, { ...d, children: [] })); + + const tree: any[] = []; + all.forEach(d => { + if (d.parentId) { + const parent = map.get(d.parentId); + if (parent) { + parent.children.push(map.get(d.id)); + } else { + tree.push(map.get(d.id)); + } + } else { + tree.push(map.get(d.id)); + } + }); + + return tree; + } + + findOne(id: string) { + return this.prisma.department.findUniqueOrThrow({ + where: { id }, + include: { children: true, parent: true }, + }); + } + + create(data: { name: string; code?: string; parentId?: string }) { + return this.prisma.department.create({ data }); + } + + async rename(id: string, name: string) { + const trimmed = name.trim(); + if (!trimmed) throw new BadRequestException('Denumirea nu poate fi goală.'); + return this.prisma.department.update({ where: { id }, data: { name: trimmed } }); + } + + async move(id: string, parentId: string | null) { + if (parentId === id) { + throw new BadRequestException('Un departament nu poate fi sub-departament al lui însuși.'); + } + if (parentId) { + // circular reference check: walk up from parentId, must not reach id + let cur = await this.prisma.department.findUnique({ where: { id: parentId } }); + while (cur?.parentId) { + if (cur.parentId === id) { + throw new BadRequestException('Mutarea creează o referință circulară în ierarhia departamentelor.'); + } + cur = await this.prisma.department.findUnique({ where: { id: cur.parentId } }); + } + } + return this.prisma.department.update({ + where: { id }, + data: { parentId: parentId ?? null }, + }); + } + + async delete(id: string) { + const contracts = await this.prisma.employmentContract.count({ where: { departmentId: id } }); + if (contracts > 0) { + throw new BadRequestException('Departamentul are contracte asociate. Transferați angajații înainte de ștergere.'); + } + const children = await this.prisma.department.count({ where: { parentId: id } }); + if (children > 0) { + throw new BadRequestException('Departamentul are sub-departamente. Ștergeți-le mai întâi.'); + } + return this.prisma.department.delete({ where: { id } }); + } +} diff --git a/apps/api/src/modules/employees/dto/create-employee.dto.ts b/apps/api/src/modules/employees/dto/create-employee.dto.ts new file mode 100644 index 0000000..8d4d59a --- /dev/null +++ b/apps/api/src/modules/employees/dto/create-employee.dto.ts @@ -0,0 +1,108 @@ +import { + IsString, + IsEnum, + IsDateString, + IsOptional, + IsEmail, + MinLength, + MaxLength, + Matches, + IsUUID, +} from 'class-validator'; +import { Sex, MaritalStatus, EmployeeStatus } from '@prisma/client'; + +// Алгоритм валидации IDNP Молдовы (контрольная цифра) +function validateIdnp(idnp: string): boolean { + if (!/^\d{13}$/.test(idnp)) return false; + const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1]; + const sum = weights.reduce( + (acc, w, i) => acc + w * parseInt(idnp[i], 10), + 0, + ); + return (sum % 10) === parseInt(idnp[12], 10); +} + +import { registerDecorator, ValidationOptions } from 'class-validator'; + +function IsIdnp(opts?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isIdnp', + target: object.constructor, + propertyName, + options: { message: 'IDNP invalid (13 cifre, cifra de control incorectă)', ...opts }, + validator: { validate: (v: unknown) => typeof v === 'string' && validateIdnp(v) }, + }); + }; +} + +export class CreateEmployeeDto { + @IsIdnp() + idnp!: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + nume!: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + prenume!: string; + + @IsOptional() + @IsString() + patronimic?: string; + + @IsOptional() + @IsString() + numeAnterior?: string; + + @IsDateString() + dataNasterii!: string; + + @IsString() + domiciliu!: string; + + @IsOptional() + @IsString() + adresaReala?: string; + + @Matches(/^\+?[0-9\s\-()]{7,20}$/) + telefonPersonal!: string; + + @IsOptional() + @Matches(/^\+?[0-9\s\-()]{7,20}$/) + telefonServiciu?: string; + + @IsOptional() + @IsEmail() + emailPersonal?: string; + + @IsOptional() + @IsEmail() + emailCorporativ?: string; + + @IsEnum(Sex) + sex!: Sex; + + @IsOptional() + @IsString() + codCpas?: string; + + @IsOptional() + @IsEnum(MaritalStatus) + stareCivila?: MaritalStatus; + + @IsOptional() + @IsUUID() + gradDizabilitateId?: string; + + @IsOptional() + @IsUUID() + recomandareInternaId?: string; + + @IsOptional() + @IsEnum(EmployeeStatus) + status?: EmployeeStatus; +} diff --git a/apps/api/src/modules/employees/dto/query-employee.dto.ts b/apps/api/src/modules/employees/dto/query-employee.dto.ts new file mode 100644 index 0000000..79a7f73 --- /dev/null +++ b/apps/api/src/modules/employees/dto/query-employee.dto.ts @@ -0,0 +1,30 @@ +import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { EmployeeStatus } from '@prisma/client'; + +export class QueryEmployeeDto { + @IsOptional() + @IsString() + search?: string; // поиск по nume, prenume, idnp + + @IsOptional() + @IsEnum(EmployeeStatus) + status?: EmployeeStatus; + + @IsOptional() + @IsString() + departmentId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(1000) + limit?: number = 20; +} diff --git a/apps/api/src/modules/employees/employees.controller.ts b/apps/api/src/modules/employees/employees.controller.ts new file mode 100644 index 0000000..02bffdf --- /dev/null +++ b/apps/api/src/modules/employees/employees.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, + ParseUUIDPipe, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { EmployeesService } from './employees.service'; +import { CreateEmployeeDto } from './dto/create-employee.dto'; +import { QueryEmployeeDto } from './dto/query-employee.dto'; + +interface AuthRequest extends Request { + user: { id: string; role: string }; +} + +@Controller('employees') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class EmployeesController { + constructor(private readonly svc: EmployeesService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director') + findAll(@Query() query: QueryEmployeeDto, @Request() req: AuthRequest) { + return this.svc.findAll(query, req.user.id, req.user.role); + } + + @Get(':id') + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie') + findOne( + @Param('id', ParseUUIDPipe) id: string, + @Query('reason') reason: string | undefined, + @Request() req: AuthRequest, + ) { + return this.svc.findOne(id, req.user.id, req.user.role, reason); + } + + @Post() + @Roles('hr_admin') + @HttpCode(HttpStatus.CREATED) + create(@Body() dto: CreateEmployeeDto, @Request() req: AuthRequest) { + return this.svc.create(dto, req.user.id, req.user.role); + } + + @Patch(':id') + @Roles('hr_admin', 'hr_specialist') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: Partial<CreateEmployeeDto>, + @Request() req: AuthRequest, + ) { + return this.svc.update(id, dto, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/employees/employees.module.ts b/apps/api/src/modules/employees/employees.module.ts new file mode 100644 index 0000000..3da13bb --- /dev/null +++ b/apps/api/src/modules/employees/employees.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { EmployeesController } from './employees.controller'; +import { EmployeesService } from './employees.service'; +import { IdentityDocumentsController } from './sub-resources/identity-documents.controller'; +import { IdentityDocumentsService } from './sub-resources/identity-documents.service'; +import { FamilyMembersController } from './sub-resources/family-members.controller'; +import { FamilyMembersService } from './sub-resources/family-members.service'; +import { EducationsController } from './sub-resources/educations.controller'; +import { EducationsService } from './sub-resources/educations.service'; +import { QualificationsController } from './sub-resources/qualifications.controller'; +import { QualificationsService } from './sub-resources/qualifications.service'; +import { TrainingsController } from './sub-resources/trainings.controller'; +import { TrainingsService } from './sub-resources/trainings.service'; +import { DisciplinarySanctionsController } from './sub-resources/disciplinary-sanctions.controller'; +import { DisciplinarySanctionsService } from './sub-resources/disciplinary-sanctions.service'; +import { BenefitController } from './sub-resources/benefit.controller'; +import { BenefitService } from './sub-resources/benefit.service'; +import { ContractsController } from './sub-resources/contracts.controller'; +import { ContractsService } from './sub-resources/contracts.service'; + +@Module({ + controllers: [ + EmployeesController, + IdentityDocumentsController, + FamilyMembersController, + EducationsController, + QualificationsController, + TrainingsController, + DisciplinarySanctionsController, + BenefitController, + ContractsController, + ], + providers: [ + EmployeesService, + IdentityDocumentsService, + FamilyMembersService, + EducationsService, + QualificationsService, + TrainingsService, + DisciplinarySanctionsService, + BenefitService, + ContractsService, + ], + exports: [EmployeesService], +}) +export class EmployeesModule {} diff --git a/apps/api/src/modules/employees/employees.service.ts b/apps/api/src/modules/employees/employees.service.ts new file mode 100644 index 0000000..8c10270 --- /dev/null +++ b/apps/api/src/modules/employees/employees.service.ts @@ -0,0 +1,184 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { AuditService } from '../../common/audit/audit.service'; +import { CreateEmployeeDto } from './dto/create-employee.dto'; +import { QueryEmployeeDto } from './dto/query-employee.dto'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class EmployeesService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async findAll(query: QueryEmployeeDto, actorId: string, actorRole: string) { + const { search, status, departmentId, page = 1, limit = 20 } = query; + + const where: Prisma.EmployeeWhereInput = { + ...(status && { status }), + ...(search && { + OR: [ + { idnp: { contains: search } }, + { nume: { contains: search, mode: 'insensitive' } }, + { prenume: { contains: search, mode: 'insensitive' } }, + ], + }), + ...(departmentId && { + contracts: { some: { departmentId, dataDemisiei: null } }, + }), + }; + + const [total, items] = await this.prisma.$transaction([ + this.prisma.employee.count({ where }), + this.prisma.employee.findMany({ + where, + skip: (page - 1) * limit, + take: limit, + orderBy: [{ nume: 'asc' }, { prenume: 'asc' }], + select: { + id: true, + idnp: true, + nume: true, + prenume: true, + sex: true, + status: true, + dataNasterii: true, + telefonPersonal: true, + emailCorporativ: true, + contracts: { + where: { dataDemisiei: null }, + take: 1, + select: { functiaOrganigrama: true, department: { select: { name: true } } }, + }, + }, + }), + ]); + + await this.audit.logRead({ + userId: actorId, + userRole: actorRole, + entity: 'Employee', + entityId: 'list', + }); + + return { total, page, limit, items }; + } + + async findOne(id: string, actorId: string, actorRole: string, reason?: string) { + const employee = await this.prisma.employee.findUnique({ + where: { id }, + include: { + identityDocuments: true, + familyMembers: { include: { tipScutire: true } }, + educations: true, + qualifications: true, + trainings: true, + disciplinarySanctions: true, + benefit: { include: { uniforma: true, halat: true, ciupici: true, vesta: true, aparatTelefon: true } }, + contracts: { include: { department: true, categoriiServicii: true } }, + gradDizabilitate: true, + medicalProfile: { include: { workplaceRiskCard: true } }, + }, + }); + + if (!employee) throw new NotFoundException(`Angajatul cu id=${id} nu există`); + + await this.audit.logRead({ + userId: actorId, + userRole: actorRole, + entity: 'Employee', + entityId: id, + reason, + }); + + return employee; + } + + async create(dto: CreateEmployeeDto, actorId: string, actorRole: string) { + const exists = await this.prisma.employee.findUnique({ + where: { idnp: dto.idnp }, + }); + if (exists) throw new ConflictException(`IDNP ${dto.idnp} deja există`); + + // Бизнес-правило: нельзя рекомендовать супруга текущего сотрудника + if (dto.recomandareInternaId) { + await this.validateRecomandare(dto.idnp, dto.recomandareInternaId); + } + + const employee = await this.prisma.employee.create({ + data: { ...dto, dataNasterii: new Date(dto.dataNasterii) }, + }); + + await this.audit.logChange({ + userId: actorId, + userRole: actorRole, + action: 'CREATE', + entity: 'Employee', + entityId: employee.id, + }); + + return employee; + } + + async update( + id: string, + dto: Partial<CreateEmployeeDto>, + actorId: string, + actorRole: string, + ) { + const existing = await this.findOne(id, actorId, actorRole); + + if (dto.recomandareInternaId) { + await this.validateRecomandare(existing.idnp, dto.recomandareInternaId); + } + + const updated = await this.prisma.employee.update({ + where: { id }, + data: { + ...dto, + ...(dto.dataNasterii && { dataNasterii: new Date(dto.dataNasterii) }), + }, + }); + + await this.audit.logChange({ + userId: actorId, + userRole: actorRole, + action: 'UPDATE', + entity: 'Employee', + entityId: id, + }); + + return updated; + } + + // Проверка: нельзя назначить рекомендатора, который является супругом текущего сотрудника + private async validateRecomandare( + currentIdnp: string, + recomandareId: string, + ) { + const current = await this.prisma.employee.findUnique({ + where: { idnp: currentIdnp }, + include: { + familyMembers: { where: { tip: { in: ['sot', 'sotie'] } } }, + }, + }); + + const spouseIdnps = current?.familyMembers.map((f) => f.idnp) ?? []; + const recommender = await this.prisma.employee.findUnique({ + where: { id: recomandareId }, + select: { idnp: true }, + }); + + if (recommender && spouseIdnps.includes(recommender.idnp)) { + throw new BadRequestException( + 'Nu se poate selecta soțul/soția angajatului ca recomandare internă', + ); + } + } +} diff --git a/apps/api/src/modules/employees/sub-resources/benefit.controller.ts b/apps/api/src/modules/employees/sub-resources/benefit.controller.ts new file mode 100644 index 0000000..b1d252a --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/benefit.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Body, Param, ParseUUIDPipe, UseGuards, Request } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { BenefitService } from './benefit.service'; +import { UpsertBenefitDto } from './upsert-benefit.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/benefit') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class BenefitController { + constructor(private readonly svc: BenefitService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findOne(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.svc.findOne(employeeId); + } + + @Post() + @Roles('hr_admin', 'hr_specialist') + upsert(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: UpsertBenefitDto, @Request() req: AuthReq) { + return this.svc.upsert(employeeId, dto, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/benefit.service.ts b/apps/api/src/modules/employees/sub-resources/benefit.service.ts new file mode 100644 index 0000000..a5c7709 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/benefit.service.ts @@ -0,0 +1,74 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { UpsertBenefitDto } from './upsert-benefit.dto'; + +const INVENTORY_FIELDS = [ + 'uniformaId', + 'halatId', + 'ciupiciId', + 'vestaId', + 'aparatTelefonId', +] as const; + +type InventoryField = (typeof INVENTORY_FIELDS)[number]; + +@Injectable() +export class BenefitService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findOne(employeeId: string) { + return this.prisma.benefit.findUnique({ + where: { employeeId }, + include: { + uniforma: true, + halat: true, + ciupici: true, + vesta: true, + aparatTelefon: true, + }, + }); + } + + async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) { + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + const old = await this.prisma.benefit.findUnique({ where: { employeeId } }); + + const record = await this.prisma.$transaction(async (tx) => { + for (const f of INVENTORY_FIELDS) { + const oldId = (old?.[f as InventoryField] as string | null | undefined) ?? null; + const newId = (dto[f] as string | null | undefined) ?? null; + if (oldId === newId) continue; + if (oldId) { + await tx.inventoryItem.update({ + where: { id: oldId }, + data: { stockQty: { increment: 1 } }, + }); + } + if (newId) { + const item = await tx.inventoryItem.update({ + where: { id: newId }, + data: { stockQty: { decrement: 1 } }, + }); + if (item.stockQty < 0) { + throw new BadRequestException(`Stoc epuizat pentru ${f}`); + } + } + } + return tx.benefit.upsert({ + where: { employeeId }, + create: { ...dto, employeeId }, + update: dto, + }); + }); + + await this.audit.logChange({ + userId, + userRole: role, + action: old ? 'UPDATE' : 'CREATE', + entity: 'Benefit', + entityId: record.id, + }); + return record; + } +} diff --git a/apps/api/src/modules/employees/sub-resources/contracts.controller.ts b/apps/api/src/modules/employees/sub-resources/contracts.controller.ts new file mode 100644 index 0000000..15030d6 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/contracts.controller.ts @@ -0,0 +1,83 @@ +import { + Controller, Get, Post, Patch, Delete, Body, Param, + ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { ContractsService } from './contracts.service'; +import { CreateContractDto } from './create-contract.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/contracts') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class ContractsController { + constructor(private readonly svc: ContractsService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.svc.findAll(employeeId); + } + + /** MAX(zile_concediu) across all CIM of this employee */ + @Get('zile-concediu-max') + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee') + getMaxZileConcediu(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.svc.getMaxZileConcediu(employeeId); + } + + @Get(':id') + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor', 'employee') + findOne( + @Param('employeeId', ParseUUIDPipe) employeeId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.svc.findOne(employeeId, id); + } + + @Post() + @Roles('hr_admin', 'hr_specialist') + @HttpCode(HttpStatus.CREATED) + create( + @Param('employeeId', ParseUUIDPipe) employeeId: string, + @Body() dto: CreateContractDto, + @Request() req: AuthReq, + ) { + return this.svc.create(employeeId, dto, req.user.id, req.user.role); + } + + @Patch(':id') + @Roles('hr_admin', 'hr_specialist') + update( + @Param('employeeId', ParseUUIDPipe) employeeId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: Partial<CreateContractDto>, + @Request() req: AuthReq, + ) { + return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); + } + + @Patch(':id/terminate') + @Roles('hr_admin', 'hr_specialist') + terminate( + @Param('employeeId', ParseUUIDPipe) employeeId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('dataDemisiei') dataDemisiei: string, + @Request() req: AuthReq, + ) { + return this.svc.terminate(employeeId, id, dataDemisiei, req.user.id, req.user.role); + } + + @Delete(':id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + remove( + @Param('employeeId', ParseUUIDPipe) employeeId: string, + @Param('id', ParseUUIDPipe) id: string, + @Request() req: AuthReq, + ) { + return this.svc.remove(employeeId, id, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/contracts.service.ts b/apps/api/src/modules/employees/sub-resources/contracts.service.ts new file mode 100644 index 0000000..8273ca3 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/contracts.service.ts @@ -0,0 +1,124 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateContractDto } from './create-contract.dto'; + +@Injectable() +export class ContractsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.employmentContract.findMany({ + where: { employeeId }, + include: { + department: true, + workSchedule: true, + categoriiServicii: true, + }, + orderBy: { dataAngajarii: 'desc' }, + }); + } + + async findOne(employeeId: string, id: string) { + const c = await this.prisma.employmentContract.findFirst({ + where: { id, employeeId }, + include: { department: true, workSchedule: true, categoriiServicii: true }, + }); + if (!c) throw new NotFoundException(); + return c; + } + + async create(employeeId: string, dto: CreateContractDto, userId: string, role: string) { + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + + const existing = await this.prisma.employmentContract.findUnique({ where: { nrCim: dto.nrCim } }); + if (existing) throw new ConflictException(`Contractul cu nr. ${dto.nrCim} există deja`); + + const { categoriiServicii, ...rest } = dto; + + const contract = await this.prisma.employmentContract.create({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { + ...rest, + employeeId, + dataSemnarii: new Date(dto.dataSemnarii), + dataAngajarii: new Date(dto.dataAngajarii), + dataDemisiei: dto.dataDemisiei ? new Date(dto.dataDemisiei) : null, + dataTerminarii: dto.dataTerminarii ? new Date(dto.dataTerminarii) : null, + salarizareDetails: (dto.salarizareDetails ?? null) as never, + clausaAditionala: (dto.clausaAditionala ?? null) as never, + categoriiServicii: categoriiServicii?.length + ? { create: categoriiServicii } + : undefined, + } as never, + include: { department: true, workSchedule: true, categoriiServicii: true }, + }); + + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'EmploymentContract', entityId: contract.id }); + return contract; + } + + async update(employeeId: string, id: string, dto: Partial<CreateContractDto>, userId: string, role: string) { + await this.findOne(employeeId, id); + + const { categoriiServicii, ...rest } = dto; + + if (rest.dataSemnarii) (rest as Record<string, unknown>).dataSemnarii = new Date(rest.dataSemnarii) as unknown; + if (rest.dataAngajarii) (rest as Record<string, unknown>).dataAngajarii = new Date(rest.dataAngajarii) as unknown; + if (rest.dataDemisiei !== undefined) (rest as Record<string, unknown>).dataDemisiei = rest.dataDemisiei ? new Date(rest.dataDemisiei) : null; + if (rest.dataTerminarii !== undefined) (rest as Record<string, unknown>).dataTerminarii = rest.dataTerminarii ? new Date(rest.dataTerminarii) : null; + + const updated = await this.prisma.employmentContract.update({ + where: { id }, + data: { + ...rest, + ...(rest.salarizareDetails !== undefined ? { salarizareDetails: (rest.salarizareDetails ?? null) as never } : {}), + ...(rest.clausaAditionala !== undefined ? { clausaAditionala: (rest.clausaAditionala ?? null) as never } : {}), + ...(categoriiServicii !== undefined ? { + categoriiServicii: { deleteMany: {}, create: categoriiServicii }, + } : {}), + } as never, + include: { department: true, workSchedule: true, categoriiServicii: true }, + }); + + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EmploymentContract', entityId: id }); + return updated; + } + + async remove(employeeId: string, id: string, userId: string, role: string) { + await this.findOne(employeeId, id); + await this.prisma.employmentContract.delete({ where: { id } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'EmploymentContract', entityId: id }); + } + + /** + * MAX(zile_concediu) across all contracts of an employee. + * `zileConcediu` lives inside the `salarizareDetails` JSONB column. + * Returns null if the employee has no contracts or no contract carries the field. + */ + async getMaxZileConcediu(employeeId: string): Promise<{ employeeId: string; maxZileConcediu: number | null; contractsConsidered: number }> { + const contracts = await this.prisma.employmentContract.findMany({ + where: { employeeId }, + select: { salarizareDetails: true }, + }); + let max: number | null = null; + for (const c of contracts) { + const raw = (c.salarizareDetails as { zileConcediu?: unknown } | null)?.zileConcediu; + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN; + if (Number.isFinite(n) && n > 0 && (max === null || n > max)) max = n; + } + return { employeeId, maxZileConcediu: max, contractsConsidered: contracts.length }; + } + + /** Terminate a contract — sets dataDemisiei, keeps record */ + async terminate(employeeId: string, id: string, dataDemisiei: string, userId: string, role: string) { + await this.findOne(employeeId, id); + const updated = await this.prisma.employmentContract.update({ + where: { id }, + data: { dataDemisiei: new Date(dataDemisiei) }, + include: { department: true, workSchedule: true, categoriiServicii: true }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EmploymentContract', entityId: id }); + return updated; + } +} diff --git a/apps/api/src/modules/employees/sub-resources/create-contract.dto.ts b/apps/api/src/modules/employees/sub-resources/create-contract.dto.ts new file mode 100644 index 0000000..20aaa4a --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-contract.dto.ts @@ -0,0 +1,78 @@ +import { + IsEnum, IsDateString, IsString, IsOptional, IsUUID, + IsArray, ValidateNested, IsIn, IsNumber, Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CimServiceCategoryDto { + @IsString() + categorieId!: string; + + @IsIn(['tarif', 'procent']) + tipRemunerare!: 'tarif' | 'procent'; + + @IsOptional() @IsNumber({ maxDecimalPlaces: 2 }) @Min(0) + sumaNeta?: number; + + @IsOptional() @IsNumber({ maxDecimalPlaces: 2 }) @Min(0) + procent?: number; +} + +export class CreateContractDto { + @IsString() + nrCim!: string; + + @IsEnum(['principal', 'secundar']) + categorie!: 'principal' | 'secundar'; + + @IsDateString() + dataSemnarii!: string; + + @IsDateString() + dataAngajarii!: string; + + @IsOptional() @IsDateString() + dataDemisiei?: string; + + @IsEnum(['determinata', 'nedeterminata', 'replasare_temporara']) + perioada!: 'determinata' | 'nedeterminata' | 'replasare_temporara'; + + @IsOptional() @IsDateString() + dataTerminarii?: string; + + @IsOptional() @IsString() + functiaClasificator?: string; + + @IsOptional() @IsString() + codFunctie?: string; + + @IsOptional() @IsString() + functiaOrganigrama?: string; + + @IsEnum(['de_baza', 'cumul']) + tipCim!: 'de_baza' | 'cumul'; + + @IsUUID() + departmentId!: string; + + @IsOptional() @IsString() + regimMunca?: string; + + @IsOptional() @IsEnum(['fix', 'pe_ore', 'in_acord']) + tipSalarizare?: 'fix' | 'pe_ore' | 'in_acord'; + + @IsOptional() + salarizareDetails?: unknown; + + @IsOptional() + clausaAditionala?: unknown; + + @IsOptional() @IsUUID() + workScheduleId?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CimServiceCategoryDto) + categoriiServicii?: CimServiceCategoryDto[]; +} diff --git a/apps/api/src/modules/employees/sub-resources/create-disciplinary-sanction.dto.ts b/apps/api/src/modules/employees/sub-resources/create-disciplinary-sanction.dto.ts new file mode 100644 index 0000000..27380eb --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-disciplinary-sanction.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsDateString } from 'class-validator'; +import { DisciplinarySanctionType } from '@prisma/client'; + +export class CreateDisciplinarySanctionDto { + @IsEnum(DisciplinarySanctionType) + tip!: DisciplinarySanctionType; + + @IsDateString() + dataAplicarii!: string; + // dataExpirarii is computed server-side: dataAplicarii + 6 months +} diff --git a/apps/api/src/modules/employees/sub-resources/create-education.dto.ts b/apps/api/src/modules/employees/sub-resources/create-education.dto.ts new file mode 100644 index 0000000..927bb0f --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-education.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator'; +import { StudyType, StudyLevel, PostUniversityType, DiplomaStatus } from '@prisma/client'; + +export class CreateEducationDto { + @IsEnum(StudyType) tipStudii!: StudyType; + @IsString() institutia!: string; + @IsString() specialitatea!: string; + @IsOptional() @IsDateString() dataAbsolvirii?: string; + @IsOptional() @IsString() nrSeriaDiploma?: string; + @IsOptional() @IsDateString() dataEmiterii?: string; + @IsOptional() @IsString() nrInregistrare?: string; + @IsOptional() @IsEnum(DiplomaStatus) confirmare?: DiplomaStatus; + @IsOptional() @IsEnum(StudyLevel) nivel?: StudyLevel; + @IsOptional() @IsEnum(PostUniversityType) tipPostuniversitar?: PostUniversityType; +} diff --git a/apps/api/src/modules/employees/sub-resources/create-family-member.dto.ts b/apps/api/src/modules/employees/sub-resources/create-family-member.dto.ts new file mode 100644 index 0000000..6627966 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-family-member.dto.ts @@ -0,0 +1,22 @@ +import { IsEnum, IsString, IsOptional, IsDateString, IsUUID } from 'class-validator'; +import { FamilyMemberType } from '@prisma/client'; + +export class CreateFamilyMemberDto { + @IsEnum(FamilyMemberType) + tip!: FamilyMemberType; + + @IsString() + numePrenume!: string; + + @IsOptional() @IsDateString() + dataNasterii?: string; + + @IsOptional() @IsString() + idnp?: string; + + @IsOptional() @IsString() + telefon?: string; + + @IsOptional() @IsUUID() + tipScutireId?: string; +} diff --git a/apps/api/src/modules/employees/sub-resources/create-identity-document.dto.ts b/apps/api/src/modules/employees/sub-resources/create-identity-document.dto.ts new file mode 100644 index 0000000..e50046d --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-identity-document.dto.ts @@ -0,0 +1,23 @@ +import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator'; +import { DocumentType } from '@prisma/client'; + +export class CreateIdentityDocumentDto { + @IsEnum(DocumentType) + tipAct!: DocumentType; + + @IsOptional() + @IsString() + seria?: string; + + @IsString() + nr!: string; + + @IsDateString() + dataEmiterii!: string; + + @IsString() + autoritateEmitenta!: string; + + @IsDateString() + dataExpirarii!: string; +} diff --git a/apps/api/src/modules/employees/sub-resources/create-qualification.dto.ts b/apps/api/src/modules/employees/sub-resources/create-qualification.dto.ts new file mode 100644 index 0000000..30a5463 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-qualification.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsString, IsOptional, IsDateString } from 'class-validator'; +import { QualificationCategory } from '@prisma/client'; + +export class CreateQualificationDto { + @IsEnum(QualificationCategory) categorie!: QualificationCategory; + @IsOptional() @IsString() specialitate?: string; + @IsOptional() @IsDateString() dataObtinerii?: string; + @IsOptional() @IsDateString() dataUltimeiConfirmari?: string; + @IsOptional() @IsDateString() dataExpirarii?: string; +} diff --git a/apps/api/src/modules/employees/sub-resources/create-training.dto.ts b/apps/api/src/modules/employees/sub-resources/create-training.dto.ts new file mode 100644 index 0000000..ed4e722 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/create-training.dto.ts @@ -0,0 +1,14 @@ +import { IsEnum, IsString, IsOptional, IsDateString, IsBoolean, IsInt, IsPositive } from 'class-validator'; +import { TrainingType } from '@prisma/client'; + +export class CreateTrainingDto { + @IsString() denumire!: string; + @IsDateString() inceput!: string; + @IsOptional() @IsDateString() sfirsit?: string; + @IsEnum(TrainingType) tip!: TrainingType; + @IsOptional() @IsString() tara?: string; + @IsOptional() @IsInt() @IsPositive() nrOre?: number; + @IsOptional() @IsString() organizatia?: string; + @IsBoolean() certificat!: boolean; + @IsOptional() @IsString() cost?: string; +} diff --git a/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.controller.ts b/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.controller.ts new file mode 100644 index 0000000..757662c --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { DisciplinarySanctionsService } from './disciplinary-sanctions.service'; +import { CreateDisciplinarySanctionDto } from './create-disciplinary-sanction.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/disciplinary-sanctions') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class DisciplinarySanctionsController { + constructor(private readonly svc: DisciplinarySanctionsService) {} + + @Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); } + + @Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateDisciplinarySanctionDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); } + + @Patch(':id') @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateDisciplinarySanctionDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); } + + @Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); } +} diff --git a/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.service.ts b/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.service.ts new file mode 100644 index 0000000..ff45ebe --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/disciplinary-sanctions.service.ts @@ -0,0 +1,52 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateDisciplinarySanctionDto } from './create-disciplinary-sanction.dto'; + +@Injectable() +export class DisciplinarySanctionsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.disciplinarySanction.findMany({ where: { employeeId }, orderBy: { dataAplicarii: 'desc' } }); + } + + async create(employeeId: string, dto: CreateDisciplinarySanctionDto, userId: string, role: string) { + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + const dataAplicarii = new Date(dto.dataAplicarii); + const dataExpirarii = new Date(dataAplicarii); + dataExpirarii.setMonth(dataExpirarii.getMonth() + 6); + + const record = await this.prisma.disciplinarySanction.create({ + data: { tip: dto.tip, dataAplicarii, dataExpirarii, employeeId }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'DisciplinarySanction', entityId: record.id }); + return record; + } + + async update(employeeId: string, id: string, dto: Partial<CreateDisciplinarySanctionDto>, userId: string, role: string) { + const existing = await this.prisma.disciplinarySanction.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + + const updateData: { tip?: typeof dto.tip; dataAplicarii?: Date; dataExpirarii?: Date } = {}; + if (dto.tip) updateData.tip = dto.tip; + if (dto.dataAplicarii) { + const dataAplicarii = new Date(dto.dataAplicarii); + const dataExpirarii = new Date(dataAplicarii); + dataExpirarii.setMonth(dataExpirarii.getMonth() + 6); + updateData.dataAplicarii = dataAplicarii; + updateData.dataExpirarii = dataExpirarii; + } + + const updated = await this.prisma.disciplinarySanction.update({ where: { id }, data: updateData }); + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'DisciplinarySanction', entityId: id }); + return updated; + } + + async remove(employeeId: string, id: string, userId: string, role: string) { + const existing = await this.prisma.disciplinarySanction.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + await this.prisma.disciplinarySanction.delete({ where: { id } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'DisciplinarySanction', entityId: id }); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/educations.controller.ts b/apps/api/src/modules/employees/sub-resources/educations.controller.ts new file mode 100644 index 0000000..1318bf6 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/educations.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { EducationsService } from './educations.service'; +import { CreateEducationDto } from './create-education.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/educations') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class EducationsController { + constructor(private readonly svc: EducationsService) {} + + @Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); } + + @Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateEducationDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); } + + @Patch(':id') @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateEducationDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); } + + @Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); } +} diff --git a/apps/api/src/modules/employees/sub-resources/educations.service.ts b/apps/api/src/modules/employees/sub-resources/educations.service.ts new file mode 100644 index 0000000..aff6f89 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/educations.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateEducationDto } from './create-education.dto'; +import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory'; + +@Injectable() +export class EducationsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.education.findMany({ where: { employeeId }, orderBy: { dataAbsolvirii: 'desc' } }); + } + + create(employeeId: string, dto: CreateEducationDto, userId: string, role: string) { + return subCreate(this.prisma, this.audit, this.prisma.education as never, employeeId, dto, userId, role, 'Education'); + } + + update(employeeId: string, id: string, dto: Partial<CreateEducationDto>, userId: string, role: string) { + return subUpdate(this.prisma.education as never, this.audit, employeeId, id, dto, userId, role, 'Education'); + } + + remove(employeeId: string, id: string, userId: string, role: string) { + return subRemove(this.prisma.education as never, this.audit, employeeId, id, userId, role, 'Education'); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/family-members.controller.ts b/apps/api/src/modules/employees/sub-resources/family-members.controller.ts new file mode 100644 index 0000000..7e7731f --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/family-members.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { FamilyMembersService } from './family-members.service'; +import { CreateFamilyMemberDto } from './create-family-member.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/family-members') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class FamilyMembersController { + constructor(private readonly svc: FamilyMembersService) {} + + @Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); } + + @Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateFamilyMemberDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); } + + @Patch(':id') @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateFamilyMemberDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); } + + @Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); } +} diff --git a/apps/api/src/modules/employees/sub-resources/family-members.service.ts b/apps/api/src/modules/employees/sub-resources/family-members.service.ts new file mode 100644 index 0000000..05d1db1 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/family-members.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateFamilyMemberDto } from './create-family-member.dto'; +import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory'; + +@Injectable() +export class FamilyMembersService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.familyMember.findMany({ where: { employeeId }, include: { tipScutire: true } }); + } + + create(employeeId: string, dto: CreateFamilyMemberDto, userId: string, role: string) { + return subCreate(this.prisma, this.audit, this.prisma.familyMember as never, employeeId, dto, userId, role, 'FamilyMember'); + } + + update(employeeId: string, id: string, dto: Partial<CreateFamilyMemberDto>, userId: string, role: string) { + return subUpdate(this.prisma.familyMember as never, this.audit, employeeId, id, dto, userId, role, 'FamilyMember'); + } + + remove(employeeId: string, id: string, userId: string, role: string) { + return subRemove(this.prisma.familyMember as never, this.audit, employeeId, id, userId, role, 'FamilyMember'); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/identity-documents.controller.ts b/apps/api/src/modules/employees/sub-resources/identity-documents.controller.ts new file mode 100644 index 0000000..205f410 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/identity-documents.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { IdentityDocumentsService } from './identity-documents.service'; +import { CreateIdentityDocumentDto } from './create-identity-document.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/identity-documents') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class IdentityDocumentsController { + constructor(private readonly svc: IdentityDocumentsService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.svc.findAll(employeeId); + } + + @Post() + @Roles('hr_admin', 'hr_specialist') + @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateIdentityDocumentDto, @Request() req: AuthReq) { + return this.svc.create(employeeId, dto, req.user.id, req.user.role); + } + + @Patch(':id') + @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateIdentityDocumentDto>, @Request() req: AuthReq) { + return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); + } + + @Delete(':id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.svc.remove(employeeId, id, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/identity-documents.service.ts b/apps/api/src/modules/employees/sub-resources/identity-documents.service.ts new file mode 100644 index 0000000..8f8f117 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/identity-documents.service.ts @@ -0,0 +1,36 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateIdentityDocumentDto } from './create-identity-document.dto'; +import { parseDateFields } from './sub-resources.service-factory'; + +@Injectable() +export class IdentityDocumentsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.identityDocument.findMany({ where: { employeeId }, orderBy: { dataExpirarii: 'asc' } }); + } + + async create(employeeId: string, dto: CreateIdentityDocumentDto, userId: string, role: string) { + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + const doc = await this.prisma.identityDocument.create({ data: { ...parseDateFields(dto), employeeId } }); + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'IdentityDocument', entityId: doc.id }); + return doc; + } + + async update(employeeId: string, id: string, dto: Partial<CreateIdentityDocumentDto>, userId: string, role: string) { + const existing = await this.prisma.identityDocument.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + const updated = await this.prisma.identityDocument.update({ where: { id }, data: parseDateFields(dto) }); + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'IdentityDocument', entityId: id }); + return updated; + } + + async remove(employeeId: string, id: string, userId: string, role: string) { + const existing = await this.prisma.identityDocument.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + await this.prisma.identityDocument.delete({ where: { id } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'IdentityDocument', entityId: id }); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/qualifications.controller.ts b/apps/api/src/modules/employees/sub-resources/qualifications.controller.ts new file mode 100644 index 0000000..100b7f4 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/qualifications.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { QualificationsService } from './qualifications.service'; +import { CreateQualificationDto } from './create-qualification.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/qualifications') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class QualificationsController { + constructor(private readonly svc: QualificationsService) {} + + @Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); } + + @Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateQualificationDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); } + + @Patch(':id') @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateQualificationDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); } + + @Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); } +} diff --git a/apps/api/src/modules/employees/sub-resources/qualifications.service.ts b/apps/api/src/modules/employees/sub-resources/qualifications.service.ts new file mode 100644 index 0000000..027cca1 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/qualifications.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateQualificationDto } from './create-qualification.dto'; +import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory'; + +@Injectable() +export class QualificationsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.qualification.findMany({ where: { employeeId }, orderBy: { dataExpirarii: 'asc' } }); + } + + create(employeeId: string, dto: CreateQualificationDto, userId: string, role: string) { + return subCreate(this.prisma, this.audit, this.prisma.qualification as never, employeeId, dto, userId, role, 'Qualification'); + } + + update(employeeId: string, id: string, dto: Partial<CreateQualificationDto>, userId: string, role: string) { + return subUpdate(this.prisma.qualification as never, this.audit, employeeId, id, dto, userId, role, 'Qualification'); + } + + remove(employeeId: string, id: string, userId: string, role: string) { + return subRemove(this.prisma.qualification as never, this.audit, employeeId, id, userId, role, 'Qualification'); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/sub-resources.service-factory.ts b/apps/api/src/modules/employees/sub-resources/sub-resources.service-factory.ts new file mode 100644 index 0000000..1c317b3 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/sub-resources.service-factory.ts @@ -0,0 +1,73 @@ +// Shared helper used by all sub-resource services +import { NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; + +// Converts "YYYY-MM-DD" string values to Date objects so Prisma @db.Date fields accept them +export function parseDateFields<T extends object>(obj: T): T { + const result: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + result[key] = new Date(value); + } else { + result[key] = value; + } + } + return result as T; +} + +export type SubResourceDelegate = { + findMany: (args: unknown) => Promise<unknown[]>; + findFirst: (args: unknown) => Promise<unknown | null>; + create: (args: unknown) => Promise<unknown>; + update: (args: unknown) => Promise<unknown>; + delete: (args: unknown) => Promise<unknown>; +}; + +export async function subCreate<T>( + prisma: PrismaService, + audit: AuditService, + delegate: SubResourceDelegate, + employeeId: string, + data: T, + userId: string, + role: string, + entity: string, +) { + await prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + const record = await delegate.create({ data: { ...parseDateFields(data as object), employeeId } }) as { id: string }; + await audit.logChange({ userId, userRole: role, action: 'CREATE', entity, entityId: record.id }); + return record; +} + +export async function subUpdate<T>( + delegate: SubResourceDelegate, + audit: AuditService, + employeeId: string, + id: string, + data: T, + userId: string, + role: string, + entity: string, +) { + const existing = await delegate.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + const updated = await delegate.update({ where: { id }, data: parseDateFields(data as object) }); + await audit.logChange({ userId, userRole: role, action: 'UPDATE', entity, entityId: id }); + return updated; +} + +export async function subRemove( + delegate: SubResourceDelegate, + audit: AuditService, + employeeId: string, + id: string, + userId: string, + role: string, + entity: string, +) { + const existing = await delegate.findFirst({ where: { id, employeeId } }); + if (!existing) throw new NotFoundException(); + await delegate.delete({ where: { id } }); + await audit.logChange({ userId, userRole: role, action: 'DELETE', entity, entityId: id }); +} diff --git a/apps/api/src/modules/employees/sub-resources/trainings.controller.ts b/apps/api/src/modules/employees/sub-resources/trainings.controller.ts new file mode 100644 index 0000000..08a88c3 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/trainings.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../../common/guards/roles.guard'; +import { Roles } from '../../../common/decorators/roles.decorator'; +import { TrainingsService } from './trainings.service'; +import { CreateTrainingDto } from './create-training.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('employees/:employeeId/trainings') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class TrainingsController { + constructor(private readonly svc: TrainingsService) {} + + @Get() @Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') + findAll(@Param('employeeId', ParseUUIDPipe) employeeId: string) { return this.svc.findAll(employeeId); } + + @Post() @Roles('hr_admin', 'hr_specialist') @HttpCode(HttpStatus.CREATED) + create(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateTrainingDto, @Request() req: AuthReq) { return this.svc.create(employeeId, dto, req.user.id, req.user.role); } + + @Patch(':id') @Roles('hr_admin', 'hr_specialist') + update(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial<CreateTrainingDto>, @Request() req: AuthReq) { return this.svc.update(employeeId, id, dto, req.user.id, req.user.role); } + + @Delete(':id') @Roles('hr_admin') @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { return this.svc.remove(employeeId, id, req.user.id, req.user.role); } +} diff --git a/apps/api/src/modules/employees/sub-resources/trainings.service.ts b/apps/api/src/modules/employees/sub-resources/trainings.service.ts new file mode 100644 index 0000000..c83036d --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/trainings.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateTrainingDto } from './create-training.dto'; +import { subCreate, subUpdate, subRemove } from './sub-resources.service-factory'; + +@Injectable() +export class TrainingsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll(employeeId: string) { + return this.prisma.training.findMany({ where: { employeeId }, orderBy: { inceput: 'desc' } }); + } + + create(employeeId: string, dto: CreateTrainingDto, userId: string, role: string) { + return subCreate(this.prisma, this.audit, this.prisma.training as never, employeeId, dto, userId, role, 'Training'); + } + + update(employeeId: string, id: string, dto: Partial<CreateTrainingDto>, userId: string, role: string) { + return subUpdate(this.prisma.training as never, this.audit, employeeId, id, dto, userId, role, 'Training'); + } + + remove(employeeId: string, id: string, userId: string, role: string) { + return subRemove(this.prisma.training as never, this.audit, employeeId, id, userId, role, 'Training'); + } +} diff --git a/apps/api/src/modules/employees/sub-resources/upsert-benefit.dto.ts b/apps/api/src/modules/employees/sub-resources/upsert-benefit.dto.ts new file mode 100644 index 0000000..878c578 --- /dev/null +++ b/apps/api/src/modules/employees/sub-resources/upsert-benefit.dto.ts @@ -0,0 +1,16 @@ +import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpsertBenefitDto { + @IsBoolean() ticheteMasa!: boolean; + @IsOptional() @IsString() valoareTichet?: string; + @IsBoolean() alimentatiePersonal!: boolean; + @IsOptional() @IsString() abonamentTel?: string; + @IsOptional() @IsString() cardCompanie?: string; + @IsOptional() @IsString() automobilServiciu?: string; + + @IsOptional() @IsUUID() uniformaId?: string | null; + @IsOptional() @IsUUID() halatId?: string | null; + @IsOptional() @IsUUID() ciupiciId?: string | null; + @IsOptional() @IsUUID() vestaId?: string | null; + @IsOptional() @IsUUID() aparatTelefonId?: string | null; +} diff --git a/apps/api/src/modules/evaluation/dto/approve-form.dto.ts b/apps/api/src/modules/evaluation/dto/approve-form.dto.ts new file mode 100644 index 0000000..fb9d5b9 --- /dev/null +++ b/apps/api/src/modules/evaluation/dto/approve-form.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ProposedCategory } from '@prisma/client'; + +export class ApproveFormDto { + @IsEnum(ProposedCategory) + categorieAprobata!: ProposedCategory; + + @IsOptional() + @IsString() + observatii?: string; +} diff --git a/apps/api/src/modules/evaluation/dto/create-campaign.dto.ts b/apps/api/src/modules/evaluation/dto/create-campaign.dto.ts new file mode 100644 index 0000000..d78b694 --- /dev/null +++ b/apps/api/src/modules/evaluation/dto/create-campaign.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsUUID, IsDateString, MinLength } from 'class-validator'; + +export class CreateCampaignDto { + @IsString() + @MinLength(2) + name!: string; + + @IsUUID() + departmentId!: string; + + // First day of the campaign month — format: YYYY-MM-01 + @IsDateString() + month!: string; +} diff --git a/apps/api/src/modules/evaluation/dto/update-form.dto.ts b/apps/api/src/modules/evaluation/dto/update-form.dto.ts new file mode 100644 index 0000000..fec5f46 --- /dev/null +++ b/apps/api/src/modules/evaluation/dto/update-form.dto.ts @@ -0,0 +1,34 @@ +import { IsEnum, IsOptional, IsBoolean, IsObject, IsString } from 'class-validator'; +import { EvaluationScore } from '@prisma/client'; + +export class UpdateFormDto { + // A. Competente clinice + @IsOptional() @IsEnum(EvaluationScore) abilitatiClinice?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) judecataClinica?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) manopere?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) gestionareaSarcinilor?: EvaluationScore; + + // B. Comunicare si empatie + @IsOptional() @IsEnum(EvaluationScore) constiintaProfesionala?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) atitudineaPacienti?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) atitudineaColegi?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) atitudineaPersonalNonMed?: EvaluationScore; + + // C. Disciplina + @IsOptional() @IsEnum(EvaluationScore) utilizareSmartphone?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) respectareaProgramului?: EvaluationScore; + @IsOptional() @IsEnum(EvaluationScore) respectareaDressCode?: EvaluationScore; + + // D. Documentatie + @IsOptional() @IsObject() testJci?: Record<string, unknown>; + @IsOptional() @IsBoolean() completareaDocMed?: boolean; + @IsOptional() @IsBoolean() perfectioneazaCunostinte?: boolean; + + // E. Candidat expert + @IsOptional() @IsBoolean() membruComitetCalitate?: boolean; + @IsOptional() @IsBoolean() functieDeMonitor?: boolean; + @IsOptional() @IsBoolean() inlocuiesteSuperiorul?: boolean; + + // F. Observatii (director overrides category separately) + @IsOptional() @IsString() observatii?: string; +} diff --git a/apps/api/src/modules/evaluation/evaluation.controller.ts b/apps/api/src/modules/evaluation/evaluation.controller.ts new file mode 100644 index 0000000..85e2c64 --- /dev/null +++ b/apps/api/src/modules/evaluation/evaluation.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, Get, Post, Patch, Delete, Body, Param, Query, + ParseUUIDPipe, UseGuards, Request, HttpCode, HttpStatus, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { EvaluationService } from './evaluation.service'; +import { CreateCampaignDto } from './dto/create-campaign.dto'; +import { UpdateFormDto } from './dto/update-form.dto'; +import { ApproveFormDto } from './dto/approve-form.dto'; +import { CampaignStatus } from '@prisma/client'; +import { IsEnum } from 'class-validator'; + +class UpdateStatusDto { + @IsEnum(CampaignStatus) + status!: CampaignStatus; +} + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('evaluation') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class EvaluationController { + constructor(private readonly svc: EvaluationService) {} + + // ── Campaigns ────────────────────────────────────────────────────────────── + + @Get('campaigns') + @Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager') + listCampaigns(@Query('departmentId') deptId?: string) { + return this.svc.findAllCampaigns(deptId); + } + + @Get('campaigns/:id') + @Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager') + getCampaign(@Param('id', ParseUUIDPipe) id: string) { + return this.svc.findCampaign(id); + } + + @Post('campaigns') + @Roles('hr_admin') + @HttpCode(HttpStatus.CREATED) + createCampaign(@Body() dto: CreateCampaignDto, @Request() req: AuthReq) { + return this.svc.createCampaign(dto, req.user.id, req.user.role); + } + + // Generate evaluation forms for all eligible employees + @Post('campaigns/:id/generate-forms') + @Roles('hr_admin') + generateForms(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.svc.generateForms(id, req.user.id, req.user.role); + } + + @Patch('campaigns/:id/status') + @Roles('hr_admin', 'nursing_director') + updateStatus(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateStatusDto, @Request() req: AuthReq) { + return this.svc.updateCampaignStatus(id, dto.status, req.user.id, req.user.role); + } + + @Delete('campaigns/:id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + deleteCampaign(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.svc.deleteCampaign(id, req.user.id, req.user.role); + } + + // ── Forms ────────────────────────────────────────────────────────────────── + + @Get('forms/:id') + @Roles('hr_admin', 'hr_specialist', 'nursing_director', 'quality_auditor', 'manager', 'employee') + getForm(@Param('id', ParseUUIDPipe) id: string) { + return this.svc.findForm(id); + } + + // quality_auditor + manager fill in scores (blocks A, B, C, D) + @Patch('forms/:id') + @Roles('hr_admin', 'quality_auditor', 'manager') + updateForm(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFormDto, @Request() req: AuthReq) { + return this.svc.updateForm(id, dto, req.user.id, req.user.role); + } + + // nursing_director approves final category + @Patch('forms/:id/approve') + @Roles('nursing_director') + approveForm(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveFormDto, @Request() req: AuthReq) { + return this.svc.approveForm(id, dto, req.user.id, req.user.role); + } + + // Academy Ocean webhook — no auth guard (uses secret header validation in prod) + @Post('webhook/academy-ocean') + @HttpCode(HttpStatus.OK) + academyOceanWebhook(@Body() payload: { + employeeIdnp: string; + score: number; + maxScore: number; + completedAt: string; + externalId: string; + }) { + return this.svc.receiveAcademyOceanWebhook(payload); + } +} diff --git a/apps/api/src/modules/evaluation/evaluation.module.ts b/apps/api/src/modules/evaluation/evaluation.module.ts new file mode 100644 index 0000000..797392a --- /dev/null +++ b/apps/api/src/modules/evaluation/evaluation.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { HttpModule } from '@nestjs/axios'; +import { EvaluationController } from './evaluation.controller'; +import { EvaluationService } from './evaluation.service'; +import { EvaluationNotificationsProcessor } from './workers/evaluation-notifications.processor'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: 'evaluation-notifications' }), + HttpModule, + ], + controllers: [EvaluationController], + providers: [EvaluationService, EvaluationNotificationsProcessor], +}) +export class EvaluationModule {} diff --git a/apps/api/src/modules/evaluation/evaluation.service.ts b/apps/api/src/modules/evaluation/evaluation.service.ts new file mode 100644 index 0000000..f04fcbc --- /dev/null +++ b/apps/api/src/modules/evaluation/evaluation.service.ts @@ -0,0 +1,278 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { AuditService } from '../../common/audit/audit.service'; +import { CreateCampaignDto } from './dto/create-campaign.dto'; +import { UpdateFormDto } from './dto/update-form.dto'; +import { ApproveFormDto } from './dto/approve-form.dto'; +import { CampaignStatus, EvaluationScore, Prisma, ProposedCategory } from '@prisma/client'; + +// Category calculation algorithm based on A+B+C scores and E expert flags +function calculateCategory(form: { + abilitatiClinice?: EvaluationScore | null; + judecataClinica?: EvaluationScore | null; + manopere?: EvaluationScore | null; + gestionareaSarcinilor?: EvaluationScore | null; + constiintaProfesionala?: EvaluationScore | null; + atitudineaPacienti?: EvaluationScore | null; + atitudineaColegi?: EvaluationScore | null; + atitudineaPersonalNonMed?: EvaluationScore | null; + utilizareSmartphone?: EvaluationScore | null; + respectareaProgramului?: EvaluationScore | null; + respectareaDressCode?: EvaluationScore | null; + membruComitetCalitate?: boolean | null; + functieDeMonitor?: boolean | null; + inlocuiesteSuperiorul?: boolean | null; +}): ProposedCategory { + const scores: (EvaluationScore | null | undefined)[] = [ + form.abilitatiClinice, form.judecataClinica, form.manopere, form.gestionareaSarcinilor, + form.constiintaProfesionala, form.atitudineaPacienti, form.atitudineaColegi, form.atitudineaPersonalNonMed, + form.utilizareSmartphone, form.respectareaProgramului, form.respectareaDressCode, + ]; + + const filled = scores.filter((s) => s != null) as EvaluationScore[]; + if (filled.length < 8) return ProposedCategory.fara; + + const scoreValue = (s: EvaluationScore) => + s === EvaluationScore.bine ? 2 : s === EvaluationScore.mediu ? 1 : 0; + + const total = filled.reduce((acc, s) => acc + scoreValue(s), 0); + const max = filled.length * 2; + const percent = total / max; + + const expertCount = [form.membruComitetCalitate, form.functieDeMonitor, form.inlocuiesteSuperiorul] + .filter(Boolean).length; + + if (percent >= 0.9 && expertCount >= 1) return ProposedCategory.superioara; + if (percent >= 0.75) return ProposedCategory.cat_I; + if (percent >= 0.5) return ProposedCategory.cat_II; + return ProposedCategory.fara; +} + +@Injectable() +export class EvaluationService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @InjectQueue('evaluation-notifications') private readonly notifQueue: Queue, + ) {} + + // ─── Campaigns ──────────────────────────────────────────── + + findAllCampaigns(departmentId?: string) { + return this.prisma.evaluationCampaign.findMany({ + where: departmentId ? { departmentId } : undefined, + orderBy: { month: 'desc' }, + include: { + department: { select: { name: true } }, + _count: { select: { forms: true } }, + }, + }); + } + + async findCampaign(id: string) { + const campaign = await this.prisma.evaluationCampaign.findUnique({ + where: { id }, + include: { + department: true, + forms: { + include: { + employee: { + select: { id: true, idnp: true, nume: true, prenume: true, status: true }, + }, + }, + orderBy: [{ categorieAprobata: 'asc' }, { completedAt: 'desc' }], + }, + }, + }); + if (!campaign) throw new NotFoundException(`Campania ${id} nu există`); + return campaign; + } + + async createCampaign(dto: CreateCampaignDto, userId: string, role: string) { + const month = new Date(dto.month); + + // Check no duplicate campaign for same dept+month + const existing = await this.prisma.evaluationCampaign.findFirst({ + where: { departmentId: dto.departmentId, month }, + }); + if (existing) throw new ConflictException('Există deja o campanie pentru această lună și departament'); + + const campaign = await this.prisma.evaluationCampaign.create({ + data: { name: dto.name, departmentId: dto.departmentId, month }, + include: { department: true }, + }); + + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'EvaluationCampaign', entityId: campaign.id }); + return campaign; + } + + // Generate forms for all eligible employees (>6 months at campaign month start) + async generateForms(campaignId: string, userId: string, role: string) { + const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id: campaignId } }); + + if (campaign.status !== CampaignStatus.draft) { + throw new BadRequestException('Formularele se pot genera doar pentru campanii în status draft'); + } + + // All active employees with a contract in this department (active or not yet dismissed) + const eligible = await this.prisma.employee.findMany({ + where: { + status: 'activ', + contracts: { + some: { + departmentId: campaign.departmentId, + OR: [{ dataDemisiei: null }, { dataDemisiei: { gt: campaign.month } }], + }, + }, + // Skip employees already in this campaign + evaluationForms: { none: { campaignId } }, + }, + select: { id: true }, + }); + + if (eligible.length === 0) return { generated: 0 }; + + await this.prisma.evaluationForm.createMany({ + data: eligible.map((e) => ({ campaignId, employeeId: e.id })), + skipDuplicates: true, + }); + + await this.prisma.evaluationCampaign.update({ + where: { id: campaignId }, + data: { status: CampaignStatus.scheduled }, + }); + + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationCampaign', entityId: campaignId }); + + // Schedule 14-day notification job + const campaignDate = new Date(campaign.month); + const notifyAt = new Date(campaignDate); + notifyAt.setDate(notifyAt.getDate() - 14); + const delay = Math.max(0, notifyAt.getTime() - Date.now()); + + await this.notifQueue.add('campaign-reminder', { campaignId }, { delay }); + + return { generated: eligible.length }; + } + + async updateCampaignStatus(id: string, status: CampaignStatus, userId: string, role: string) { + const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id } }); + + const allowed: Record<CampaignStatus, CampaignStatus[]> = { + draft: [CampaignStatus.scheduled], + scheduled: [CampaignStatus.in_progress, CampaignStatus.draft], + in_progress: [CampaignStatus.closed], + closed: [], + }; + + if (!allowed[campaign.status].includes(status)) { + throw new BadRequestException(`Tranziție invalidă: ${campaign.status} → ${status}`); + } + + const updated = await this.prisma.evaluationCampaign.update({ where: { id }, data: { status } }); + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationCampaign', entityId: id }); + return updated; + } + + async deleteCampaign(id: string, userId: string, role: string) { + const campaign = await this.prisma.evaluationCampaign.findUniqueOrThrow({ where: { id } }); + if (campaign.status === CampaignStatus.in_progress) { + throw new BadRequestException('Nu se poate șterge o campanie în desfășurare.'); + } + await this.prisma.evaluationCampaign.delete({ where: { id } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'EvaluationCampaign', entityId: id }); + } + + // ─── Forms ──────────────────────────────────────────────── + + findForm(id: string) { + return this.prisma.evaluationForm.findUniqueOrThrow({ + where: { id }, + include: { + employee: { + select: { + id: true, idnp: true, nume: true, prenume: true, status: true, + qualifications: { orderBy: { dataExpirarii: 'desc' }, take: 1 }, + disciplinarySanctions: { where: { isStinsa: false } }, + }, + }, + campaign: { include: { department: { select: { name: true } } } }, + }, + }); + } + + async updateForm(id: string, dto: UpdateFormDto, userId: string, role: string) { + const form = await this.prisma.evaluationForm.findUniqueOrThrow({ where: { id } }); + + // Merge with existing scores to recalculate category + const merged = { ...form, ...dto }; + const categorieCalculata = calculateCategory(merged); + + const updated = await this.prisma.evaluationForm.update({ + where: { id }, + data: { ...dto, categorieCalculata } as Prisma.EvaluationFormUpdateInput, + }); + + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationForm', entityId: id }); + return updated; + } + + async approveForm(id: string, dto: ApproveFormDto, userId: string, role: string) { + const updated = await this.prisma.evaluationForm.update({ + where: { id }, + data: { + categorieAprobata: dto.categorieAprobata, + observatii: dto.observatii, + completedAt: new Date(), + }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'EvaluationForm', entityId: id }); + return updated; + } + + // Academy Ocean webhook — auto-fill D1 JCI test result + async receiveAcademyOceanWebhook(payload: { + employeeIdnp: string; + score: number; + maxScore: number; + completedAt: string; + externalId: string; + }) { + const employee = await this.prisma.employee.findUnique({ where: { idnp: payload.employeeIdnp } }); + if (!employee) return { accepted: false, reason: 'Employee not found' }; + + // Find open form for this employee (in_progress campaign) + const form = await this.prisma.evaluationForm.findFirst({ + where: { + employeeId: employee.id, + campaign: { status: CampaignStatus.in_progress }, + completedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + if (!form) return { accepted: false, reason: 'No open form found' }; + + await this.prisma.evaluationForm.update({ + where: { id: form.id }, + data: { + testJci: { + score: payload.score, + max_score: payload.maxScore, + percent: Math.round((payload.score / payload.maxScore) * 100), + completed_at: payload.completedAt, + source: 'academy_ocean', + external_id: payload.externalId, + }, + }, + }); + + return { accepted: true, formId: form.id }; + } +} diff --git a/apps/api/src/modules/evaluation/workers/evaluation-notifications.processor.ts b/apps/api/src/modules/evaluation/workers/evaluation-notifications.processor.ts new file mode 100644 index 0000000..351c322 --- /dev/null +++ b/apps/api/src/modules/evaluation/workers/evaluation-notifications.processor.ts @@ -0,0 +1,63 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; + +interface CampaignReminderJob { campaignId: string } + +@Injectable() +@Processor('evaluation-notifications') +export class EvaluationNotificationsProcessor { + private readonly logger = new Logger(EvaluationNotificationsProcessor.name); + + constructor( + private readonly prisma: PrismaService, + private readonly http: HttpService, + ) {} + + @Process('campaign-reminder') + async handleCampaignReminder(job: Job<CampaignReminderJob>) { + const { campaignId } = job.data; + + const campaign = await this.prisma.evaluationCampaign.findUnique({ + where: { id: campaignId }, + include: { + department: true, + forms: { include: { employee: { select: { idnp: true, nume: true, prenume: true } } } }, + }, + }); + + if (!campaign) { + this.logger.warn(`Campaign ${campaignId} not found, skipping notification`); + return; + } + + const employeeList = campaign.forms.map((f) => + `${f.employee.nume} ${f.employee.prenume} (${f.employee.idnp})` + ).join('\n'); + + const n8nWebhook = process.env.N8N_WEBHOOK_BASE; + if (!n8nWebhook) { + this.logger.warn('N8N_WEBHOOK_BASE not set, skipping notification'); + return; + } + + try { + await firstValueFrom( + this.http.post(`${n8nWebhook}/evaluation-reminder`, { + type: 'evaluation-reminder', + campaignName: campaign.name, + departmentName: campaign.department.name, + month: campaign.month, + employeeCount: campaign.forms.length, + employeeList, + }), + ); + this.logger.log(`Evaluation reminder sent for campaign ${campaign.name}`); + } catch (err) { + this.logger.error(`Failed to send evaluation reminder: ${(err as Error).message}`); + } + } +} diff --git a/apps/api/src/modules/inventory/dto/adjust-stock.dto.ts b/apps/api/src/modules/inventory/dto/adjust-stock.dto.ts new file mode 100644 index 0000000..7f1d981 --- /dev/null +++ b/apps/api/src/modules/inventory/dto/adjust-stock.dto.ts @@ -0,0 +1,6 @@ +import { IsNumber, IsString } from 'class-validator'; + +export class AdjustStockDto { + @IsNumber() delta!: number; + @IsString() reason!: string; +} diff --git a/apps/api/src/modules/inventory/dto/create-inventory.dto.ts b/apps/api/src/modules/inventory/dto/create-inventory.dto.ts new file mode 100644 index 0000000..1a3f9e1 --- /dev/null +++ b/apps/api/src/modules/inventory/dto/create-inventory.dto.ts @@ -0,0 +1,13 @@ +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { InventoryItemType } from '@prisma/client'; + +export class CreateInventoryDto { + @IsString() sku!: string; + @IsString() name!: string; + @IsEnum(InventoryItemType) type!: InventoryItemType; + @IsOptional() @IsString() size?: string; + @IsOptional() @IsString() color?: string; + @IsOptional() @IsNumber() pricePerUnit?: number; + @IsNumber() @Min(0) stockQty!: number; + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/modules/inventory/dto/list-query.dto.ts b/apps/api/src/modules/inventory/dto/list-query.dto.ts new file mode 100644 index 0000000..5c45c1d --- /dev/null +++ b/apps/api/src/modules/inventory/dto/list-query.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { InventoryItemType } from '@prisma/client'; + +export class InventoryQueryDto { + @IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType; + @IsOptional() @Type(() => Boolean) @IsBoolean() active?: boolean; + @IsOptional() @IsString() search?: string; + @IsOptional() @Type(() => Number) @IsInt() page?: number = 1; + @IsOptional() @Type(() => Number) @IsInt() limit?: number = 50; +} diff --git a/apps/api/src/modules/inventory/dto/update-inventory.dto.ts b/apps/api/src/modules/inventory/dto/update-inventory.dto.ts new file mode 100644 index 0000000..925adf1 --- /dev/null +++ b/apps/api/src/modules/inventory/dto/update-inventory.dto.ts @@ -0,0 +1,14 @@ +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { InventoryItemType } from '@prisma/client'; + +// Manual partial of CreateInventoryDto (avoids @nestjs/mapped-types dep). +export class UpdateInventoryDto { + @IsOptional() @IsString() sku?: string; + @IsOptional() @IsString() name?: string; + @IsOptional() @IsEnum(InventoryItemType) type?: InventoryItemType; + @IsOptional() @IsString() size?: string; + @IsOptional() @IsString() color?: string; + @IsOptional() @IsNumber() pricePerUnit?: number; + @IsOptional() @IsNumber() @Min(0) stockQty?: number; + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/modules/inventory/inventory.controller.ts b/apps/api/src/modules/inventory/inventory.controller.ts new file mode 100644 index 0000000..c03c85f --- /dev/null +++ b/apps/api/src/modules/inventory/inventory.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Request, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { InventoryService } from './inventory.service'; +import { CreateInventoryDto } from './dto/create-inventory.dto'; +import { UpdateInventoryDto } from './dto/update-inventory.dto'; +import { InventoryQueryDto } from './dto/list-query.dto'; +import { AdjustStockDto } from './dto/adjust-stock.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('inventory') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class InventoryController { + constructor(private readonly svc: InventoryService) {} + + @Get() + @Roles('hr_admin', 'hr_specialist') + list(@Query() q: InventoryQueryDto, @Request() req: AuthReq) { + return this.svc.list(q, req.user.id, req.user.role); + } + + @Get(':id') + @Roles('hr_admin', 'hr_specialist') + findOne(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.svc.findOne(id, req.user.id, req.user.role); + } + + @Post() + @Roles('hr_admin') + create(@Body() dto: CreateInventoryDto, @Request() req: AuthReq) { + return this.svc.create(dto, req.user.id, req.user.role); + } + + @Patch(':id') + @Roles('hr_admin') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateInventoryDto, + @Request() req: AuthReq, + ) { + return this.svc.update(id, dto, req.user.id, req.user.role); + } + + @Delete(':id') + @Roles('hr_admin') + remove(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.svc.remove(id, req.user.id, req.user.role); + } + + @Post(':id/adjust-stock') + @Roles('hr_admin') + adjust( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AdjustStockDto, + @Request() req: AuthReq, + ) { + return this.svc.adjustStock(id, dto, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/inventory/inventory.module.ts b/apps/api/src/modules/inventory/inventory.module.ts new file mode 100644 index 0000000..4795564 --- /dev/null +++ b/apps/api/src/modules/inventory/inventory.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { InventoryController } from './inventory.controller'; +import { InventoryService } from './inventory.service'; + +@Module({ + controllers: [InventoryController], + providers: [InventoryService], +}) +export class InventoryModule {} diff --git a/apps/api/src/modules/inventory/inventory.service.ts b/apps/api/src/modules/inventory/inventory.service.ts new file mode 100644 index 0000000..e6edc8a --- /dev/null +++ b/apps/api/src/modules/inventory/inventory.service.ts @@ -0,0 +1,131 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { AuditService } from '../../common/audit/audit.service'; +import { CreateInventoryDto } from './dto/create-inventory.dto'; +import { UpdateInventoryDto } from './dto/update-inventory.dto'; +import { InventoryQueryDto } from './dto/list-query.dto'; +import { AdjustStockDto } from './dto/adjust-stock.dto'; + +@Injectable() +export class InventoryService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async list(q: InventoryQueryDto, userId: string, role: string) { + const where: Prisma.InventoryItemWhereInput = {}; + if (q.type) where.type = q.type; + if (q.active !== undefined) where.active = q.active; + if (q.search) { + where.OR = [ + { sku: { contains: q.search, mode: 'insensitive' } }, + { name: { contains: q.search, mode: 'insensitive' } }, + ]; + } + const limit = Math.min(q.limit ?? 50, 200); + const page = q.page ?? 1; + const [total, items] = await this.prisma.$transaction([ + this.prisma.inventoryItem.count({ where }), + this.prisma.inventoryItem.findMany({ + where, + orderBy: { name: 'asc' }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: 'LIST' }); + return { total, page, limit, items }; + } + + async findOne(id: string, userId: string, role: string) { + const item = await this.prisma.inventoryItem.findUniqueOrThrow({ where: { id } }); + await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: id }); + return item; + } + + async create(dto: CreateInventoryDto, userId: string, role: string) { + const item = await this.prisma.inventoryItem.create({ data: dto }); + await this.audit.logChange({ + userId, + userRole: role, + action: 'CREATE', + entity: 'InventoryItem', + entityId: item.id, + }); + return item; + } + + async update(id: string, dto: UpdateInventoryDto, userId: string, role: string) { + const item = await this.prisma.inventoryItem.update({ where: { id }, data: dto }); + await this.audit.logChange({ + userId, + userRole: role, + action: 'UPDATE', + entity: 'InventoryItem', + entityId: id, + }); + return item; + } + + async remove(id: string, userId: string, role: string) { + const used = await this.prisma.benefit.count({ + where: { + OR: [ + { uniformaId: id }, + { halatId: id }, + { ciupiciId: id }, + { vestaId: id }, + { aparatTelefonId: id }, + ], + }, + }); + if (used > 0) { + await this.prisma.inventoryItem.update({ where: { id }, data: { active: false } }); + await this.audit.logChange({ + userId, + userRole: role, + action: 'UPDATE', + entity: 'InventoryItem', + entityId: id, + field: 'active', + newValue: 'false', + }); + return { softDeleted: true }; + } + await this.prisma.inventoryItem.delete({ where: { id } }); + await this.audit.logChange({ + userId, + userRole: role, + action: 'DELETE', + entity: 'InventoryItem', + entityId: id, + }); + return { deleted: true }; + } + + async adjustStock(id: string, dto: AdjustStockDto, userId: string, role: string) { + const item = await this.prisma.$transaction(async (tx) => { + const updated = await tx.inventoryItem.update({ + where: { id }, + data: { stockQty: { increment: dto.delta } }, + }); + if (updated.stockQty < 0) { + throw new BadRequestException('Stoc negativ nu este permis'); + } + return updated; + }); + await this.audit.logChange({ + userId, + userRole: role, + action: 'UPDATE', + entity: 'InventoryItem', + entityId: id, + field: 'stockQty', + newValue: String(item.stockQty), + reason: dto.reason, + }); + return item; + } +} diff --git a/apps/api/src/modules/medical/dto/checkup.dto.ts b/apps/api/src/modules/medical/dto/checkup.dto.ts new file mode 100644 index 0000000..64d8f2e --- /dev/null +++ b/apps/api/src/modules/medical/dto/checkup.dto.ts @@ -0,0 +1,60 @@ +import { + IsEnum, IsDateString, IsOptional, IsString, IsArray, ArrayNotEmpty, IsUUID, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { MedicalCheckupType, MedicalVerdict } from '@prisma/client'; + +export class CreateCheckupDto { + @IsEnum(MedicalCheckupType) + tip!: MedicalCheckupType; + + @IsDateString() + dataPlanificata!: string; +} + +export class CompleteCheckupDto { + @IsEnum(MedicalVerdict) + verdict!: MedicalVerdict; + + @IsDateString() + dataEfectuata!: string; + + @IsOptional() + @IsString() + recomandari?: string; + + @IsOptional() + @IsDateString() + valabilPanaLa?: string; + + @IsOptional() + @IsString() + semnatDe?: string; +} + +export class DocumentContextDto { + @IsOptional() @IsString() telefon?: string; + @IsOptional() @IsString() fax?: string; + @IsOptional() @IsString() email?: string; + @IsOptional() @IsString() solicitant?: string; + @IsOptional() @IsString() functia?: string; +} + +export class BulkInitiateDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + employeeIds!: string[]; + + @IsEnum(MedicalCheckupType) + tip!: MedicalCheckupType; + + @IsDateString() + dataPlanificata!: string; + + @IsOptional() + @ValidateNested() + @Type(() => DocumentContextDto) + documentContext?: DocumentContextDto; +} diff --git a/apps/api/src/modules/medical/dto/medical-profile.dto.ts b/apps/api/src/modules/medical/dto/medical-profile.dto.ts new file mode 100644 index 0000000..46df343 --- /dev/null +++ b/apps/api/src/modules/medical/dto/medical-profile.dto.ts @@ -0,0 +1,41 @@ +import { + IsString, IsOptional, IsBoolean, IsUUID, IsDateString, IsInt, IsNumber, Min, + IsEnum, IsArray, ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { OverexposureKind } from '@prisma/client'; + +// Supraexpunere la radiații ionizante — rând din Anexa 4B. +export class OverexposureDto { + @IsEnum(OverexposureKind) fel!: OverexposureKind; + @IsOptional() @IsString() tipExpunere?: string; + @IsOptional() @IsDateString() data?: string; + @IsOptional() @IsNumber() dozaMsv?: number; +} + +export class UpsertMedicalProfileDto { + @IsOptional() @IsString() ocupatieCorm?: string; + + @IsOptional() @IsUUID() + workplaceRiskCardId?: string; + + @IsOptional() @IsDateString() + dataUltimControlMedical?: string; + + @IsBoolean() + expusRadiatiiIonizante!: boolean; + + // Conditional fields — required only when expusRadiatiiIonizante = true + @IsOptional() @IsDateString() dataIntrarii?: string; + @IsOptional() @IsString() expunereAnterioaraPerioda?: string; + @IsOptional() @IsInt() @Min(0) expunereAnterioaraAni?: number; + @IsOptional() @IsNumber() dozaCumulataExternaMsv?: number; + @IsOptional() @IsNumber() dozaCumulataInternaMsv?: number; + + // Supraexpuneri (Anexa 4B) — set complet (înlocuiește la salvare) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OverexposureDto) + overexposures?: OverexposureDto[]; +} diff --git a/apps/api/src/modules/medical/dto/risk-card.dto.ts b/apps/api/src/modules/medical/dto/risk-card.dto.ts new file mode 100644 index 0000000..324507d --- /dev/null +++ b/apps/api/src/modules/medical/dto/risk-card.dto.ts @@ -0,0 +1,78 @@ +import { + IsString, IsObject, MinLength, IsOptional, IsInt, IsBoolean, IsEnum, + IsArray, ValidateNested, IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { RiskExposureType } from '@prisma/client'; + +// Un rând dintr-un tabel factorial al Anexei 4 (NU-10-MS-2026). +export class RiskExposureDto { + @IsEnum(RiskExposureType) tip!: RiskExposureType; + @IsString() @MinLength(1) denumire!: string; + @IsOptional() @IsString() cas?: string; + @IsOptional() @IsString() einecs?: string; + @IsOptional() @IsString() clasificare?: string; + @IsOptional() @IsString() zonaAfectata?: string; + @IsOptional() @IsString() timpExpunere?: string; + @IsOptional() @IsString() vep?: string; + @IsOptional() @IsString() vlep?: string; + @IsOptional() @IsString() caracteristici?: string; + @IsOptional() @IsString() procesVerbal?: string; +} + +// Câmpurile comune (create + update) ale cardului de risc / Anexa 4. +class RiskCardBaseDto { + // legacy: { chimici, fizici, biologici, ergonomici, psihosociali } + @IsOptional() @IsObject() riskFactors?: Record<string, string[]>; + + // ── Antet Anexa 4 ── + @IsOptional() @IsString() filiala?: string; + @IsOptional() @IsString() adresaFiliala?: string; + @IsOptional() @IsString() telefonFiliala?: string; + @IsOptional() @IsString() caemPrimeleDouaCifre?: string; + @IsOptional() @IsString() cormSubgrupaMajora?: string; + @IsOptional() @IsString() directiaSectiaSectorul?: string; + @IsOptional() @IsString() numarulLoculuiDeMunca?: string; + @IsOptional() @IsString() caemDiviziune?: string; + @IsOptional() @IsString() clasaConditiilorDeMunca?: string; + @IsOptional() @IsInt() numarLucratoriPosibili?: number; + @IsOptional() @IsIn(['STANDARD', 'DISTANTA_DIGITAL']) tipFisa?: string; + + // ── Bloc descriptiv (checkbox-uri / descrieri) ── + @IsOptional() @IsObject() evaluareDetalii?: Record<string, unknown>; + + // ── Radiații ionizante (per loc de muncă) ── + @IsOptional() @IsBoolean() radiatiiIonizante?: boolean; + @IsOptional() @IsString() radiatiiGrupa?: string; + @IsOptional() @IsString() radiatiiAparatura?: string; + @IsOptional() @IsString() radiatiiSurse?: string; + @IsOptional() @IsString() radiatiiTipExpunere?: string; + @IsOptional() @IsString() radiatiiMasuriProtectie?: string; + + // ── Subsol ── + @IsOptional() @IsString() mijloaceProtectieColectiva?: string; + @IsOptional() @IsString() mijloaceProtectieIndividuala?: string; + @IsOptional() @IsString() echipamentLucru?: string; + @IsOptional() @IsString() observatii?: string; + @IsOptional() @IsObject() anexeIgienicoSanitare?: Record<string, unknown>; + + // ── Tabele factoriale ── + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RiskExposureDto) + exposures?: RiskExposureDto[]; +} + +export class CreateRiskCardDto extends RiskCardBaseDto { + @IsString() + @MinLength(2) + name!: string; +} + +export class UpdateRiskCardDto extends RiskCardBaseDto { + @IsOptional() + @IsString() + @MinLength(2) + name?: string; +} diff --git a/apps/api/src/modules/medical/medical.controller.ts b/apps/api/src/modules/medical/medical.controller.ts new file mode 100644 index 0000000..4bf6a46 --- /dev/null +++ b/apps/api/src/modules/medical/medical.controller.ts @@ -0,0 +1,155 @@ +import { + Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, + UseGuards, Request, HttpCode, HttpStatus, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { RiskCardsService } from './services/risk-cards.service'; +import { MedicalProfileService } from './services/medical-profile.service'; +import { CheckupService } from './services/checkup.service'; +import { BulkService } from './services/bulk.service'; +import { StorageService } from './services/storage.service'; +import { CreateRiskCardDto, UpdateRiskCardDto } from './dto/risk-card.dto'; +import { UpsertMedicalProfileDto } from './dto/medical-profile.dto'; +import { CreateCheckupDto, CompleteCheckupDto, BulkInitiateDto } from './dto/checkup.dto'; + +interface AuthReq extends Request { user: { id: string; role: string } } + +@Controller('medical') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class MedicalController { + constructor( + private readonly riskCards: RiskCardsService, + private readonly profiles: MedicalProfileService, + private readonly checkups: CheckupService, + private readonly bulk: BulkService, + private readonly storage: StorageService, + ) {} + + // ─── Risk Cards ───────────────────────────────────────────────── + + @Get('risk-cards') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie') + listRiskCards() { return this.riskCards.findAll(); } + + @Get('risk-cards/:id') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie') + getRiskCard(@Param('id', ParseUUIDPipe) id: string) { return this.riskCards.findOne(id); } + + @Post('risk-cards') + @Roles('hr_admin') + @HttpCode(HttpStatus.CREATED) + createRiskCard(@Body() dto: CreateRiskCardDto, @Request() req: AuthReq) { + return this.riskCards.create(dto, req.user.id, req.user.role); + } + + @Patch('risk-cards/:id') + @Roles('hr_admin') + updateRiskCard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRiskCardDto, @Request() req: AuthReq) { + return this.riskCards.update(id, dto, req.user.id, req.user.role); + } + + @Delete('risk-cards/:id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + deleteRiskCard(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.riskCards.remove(id, req.user.id, req.user.role); + } + + // ─── Employee Medical Profile ─────────────────────────────────── + + @Get('profiles/:employeeId') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee') + getProfile(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.profiles.findOne(employeeId); + } + + @Post('profiles/:employeeId') + @Roles('hr_admin', 'hr_specialist') + upsertProfile(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: UpsertMedicalProfileDto, @Request() req: AuthReq) { + return this.profiles.upsert(employeeId, dto, req.user.id, req.user.role); + } + + // ─── Medical Checkups ────────────────────────────────────────── + + @Get('checkups/employee/:employeeId') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee') + listByEmployee(@Param('employeeId', ParseUUIDPipe) employeeId: string) { + return this.checkups.findByEmployee(employeeId); + } + + @Get('checkups/:id') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie') + getCheckup(@Param('id', ParseUUIDPipe) id: string) { return this.checkups.findOne(id); } + + @Post('checkups/employee/:employeeId') + @Roles('hr_admin', 'hr_specialist') + @HttpCode(HttpStatus.CREATED) + createCheckup(@Param('employeeId', ParseUUIDPipe) employeeId: string, @Body() dto: CreateCheckupDto, @Request() req: AuthReq) { + return this.checkups.create(employeeId, dto, req.user.id, req.user.role); + } + + // medic_familie completes the checkup with verdict + @Patch('checkups/:id/complete') + @Roles('medic_familie') + completeCheckup(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CompleteCheckupDto, @Request() req: AuthReq) { + return this.checkups.complete(id, dto, req.user.id, req.user.role); + } + + // medic_familie inbox + @Get('checkups/inbox/pending') + @Roles('medic_familie', 'hr_admin') + pendingForMedic() { return this.checkups.findPendingForMedic(); } + + // ─── Bulk Initiation ─────────────────────────────────────────── + + @Post('bulk/initiate') + @Roles('hr_admin') + bulkInitiate(@Body() dto: BulkInitiateDto, @Request() req: AuthReq) { + return this.bulk.initiate(dto, req.user.id, req.user.role); + } + + // Dashboard: employees overdue / due in 30 days + @Get('upcoming-expirations') + @Roles('hr_admin', 'hr_specialist', 'manager') + upcoming() { return this.bulk.upcomingExpirations(); } + + // ─── Document download (presigned URL) ───────────────────────── + + @Get('documents/presign') + @Roles('hr_admin', 'hr_specialist', 'manager', 'medic_familie', 'employee') + async presignDocument(@Query('key') key: string) { + return { url: await this.storage.presignedUrl(key) }; + } + + // ─── Delete a single document from a checkup ─────────────────── + + @Delete('checkups/:id') + @Roles('hr_admin') + @HttpCode(HttpStatus.NO_CONTENT) + removeCheckup(@Param('id', ParseUUIDPipe) id: string, @Request() req: AuthReq) { + return this.checkups.remove(id, req.user.id, req.user.role); + } + + @Delete('checkups/:id/documents') + @Roles('hr_admin', 'hr_specialist') + @HttpCode(HttpStatus.NO_CONTENT) + deleteDocument( + @Param('id', ParseUUIDPipe) id: string, + @Query('name') name: string, + @Request() req: AuthReq, + ) { + return this.checkups.deleteDocument(id, name, req.user.id, req.user.role); + } + + @Delete('checkups/:id/documents/all') + @Roles('hr_admin', 'hr_specialist') + @HttpCode(HttpStatus.NO_CONTENT) + deleteAllDocuments( + @Param('id', ParseUUIDPipe) id: string, + @Request() req: AuthReq, + ) { + return this.checkups.deleteAllDocuments(id, req.user.id, req.user.role); + } +} diff --git a/apps/api/src/modules/medical/medical.module.ts b/apps/api/src/modules/medical/medical.module.ts new file mode 100644 index 0000000..827f6ea --- /dev/null +++ b/apps/api/src/modules/medical/medical.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { MedicalController } from './medical.controller'; +import { RiskCardsService } from './services/risk-cards.service'; +import { MedicalProfileService } from './services/medical-profile.service'; +import { CheckupService } from './services/checkup.service'; +import { BulkService } from './services/bulk.service'; +import { DocumentGeneratorService } from './services/document-generator.service'; +import { DocxTemplateService } from './services/docx-template.service'; +import { StorageService } from './services/storage.service'; + +@Module({ + controllers: [MedicalController], + providers: [ + RiskCardsService, + MedicalProfileService, + CheckupService, + BulkService, + DocumentGeneratorService, + DocxTemplateService, + StorageService, + ], +}) +export class MedicalModule {} diff --git a/apps/api/src/modules/medical/services/bulk.service.ts b/apps/api/src/modules/medical/services/bulk.service.ts new file mode 100644 index 0000000..853e7b0 --- /dev/null +++ b/apps/api/src/modules/medical/services/bulk.service.ts @@ -0,0 +1,182 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { DocumentGeneratorService, GeneratedDoc } from './document-generator.service'; +import { BulkInitiateDto } from '../dto/checkup.dto'; + +@Injectable() +export class BulkService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly docs: DocumentGeneratorService, + ) {} + + /** + * Initiate medical control for a batch of employees: + * 1. Group by workplace_risk_card_id + * 2. For each group, generate documents (Anexa 4, fisa solicitare, optional 4B, Anexa 6 per employee) + * 3. Create MedicalCheckup record per employee with documenteGenerate links + */ + async initiate(dto: BulkInitiateDto, userId: string, role: string) { + const employees = await this.prisma.employee.findMany({ + where: { id: { in: dto.employeeIds } }, + include: { + medicalProfile: { + include: { workplaceRiskCard: { include: { exposures: true } }, overexposures: true }, + }, + }, + }); + + if (employees.length !== dto.employeeIds.length) { + throw new BadRequestException('Unul sau mai mulți angajați nu au fost găsiți'); + } + + const missingProfile = employees.filter((e) => !e.medicalProfile?.workplaceRiskCardId); + if (missingProfile.length > 0) { + throw new BadRequestException( + `Angajații fără carte de risc atribuită: ${missingProfile.map((e) => `${e.nume} ${e.prenume}`).join(', ')}`, + ); + } + + // Group by riskCardId + const groups = new Map<string, typeof employees>(); + for (const emp of employees) { + const cardId = emp.medicalProfile!.workplaceRiskCardId!; + if (!groups.has(cardId)) groups.set(cardId, []); + groups.get(cardId)!.push(emp); + } + + // Pre-fetch active contracts for department names + const contractMap = new Map<string, string>(); + for (const emp of employees) { + const contract = await this.prisma.employmentContract.findFirst({ + where: { employeeId: emp.id, dataDemisiei: null }, + include: { department: true }, + }); + if (contract) contractMap.set(emp.id, contract.department.name); + } + + const batchId = randomUUID(); + const createdCheckups: { employeeId: string; checkupId: string; documents: GeneratedDoc[] }[] = []; + + for (const [, groupEmployees] of groups) { + const riskCard = groupEmployees[0].medicalProfile!.workplaceRiskCard!; + const deptName = contractMap.get(groupEmployees[0].id); + + const { groupDocs, perEmployee } = await this.docs.generateForGroup( + { + id: riskCard.id, + name: riskCard.name, + filiala: riskCard.filiala, + adresaFiliala: riskCard.adresaFiliala, + telefonFiliala: riskCard.telefonFiliala, + caemPrimeleDouaCifre: riskCard.caemPrimeleDouaCifre, + cormSubgrupaMajora: riskCard.cormSubgrupaMajora, + directiaSectiaSectorul: riskCard.directiaSectiaSectorul, + numarulLoculuiDeMunca: riskCard.numarulLoculuiDeMunca, + caemDiviziune: riskCard.caemDiviziune, + clasaConditiilorDeMunca: riskCard.clasaConditiilorDeMunca, + numarLucratoriPosibili: riskCard.numarLucratoriPosibili, + tipFisa: riskCard.tipFisa, + evaluareDetalii: riskCard.evaluareDetalii as Record<string, unknown> | null, + anexeIgienicoSanitare: riskCard.anexeIgienicoSanitare as Record<string, unknown> | null, + mijloaceProtectieColectiva: riskCard.mijloaceProtectieColectiva, + mijloaceProtectieIndividuala: riskCard.mijloaceProtectieIndividuala, + echipamentLucru: riskCard.echipamentLucru, + observatii: riskCard.observatii, + radiatiiIonizante: riskCard.radiatiiIonizante, + radiatiiGrupa: riskCard.radiatiiGrupa, + radiatiiAparatura: riskCard.radiatiiAparatura, + radiatiiSurse: riskCard.radiatiiSurse, + radiatiiTipExpunere: riskCard.radiatiiTipExpunere, + radiatiiMasuriProtectie: riskCard.radiatiiMasuriProtectie, + exposures: riskCard.exposures, + }, + groupEmployees.map((e) => ({ + id: e.id, + idnp: e.idnp, + nume: e.nume, + prenume: e.prenume, + dataNasterii: e.dataNasterii.toISOString(), + ocupatieCorm: e.medicalProfile!.ocupatieCorm, + expusRadiatiiIonizante: e.medicalProfile!.expusRadiatiiIonizante, + dataIntrarii: e.medicalProfile!.dataIntrarii?.toISOString(), + expunereAnterioaraPerioda: e.medicalProfile!.expunereAnterioaraPerioda, + expunereAnterioaraAni: e.medicalProfile!.expunereAnterioaraAni, + dozaCumulataExternaMsv: e.medicalProfile!.dozaCumulataExternaMsv?.toString(), + dozaCumulataInternaMsv: e.medicalProfile!.dozaCumulataInternaMsv?.toString(), + departmentName: contractMap.get(e.id), + overexposures: e.medicalProfile!.overexposures?.map((o) => ({ + fel: o.fel, + tipExpunere: o.tipExpunere, + data: o.data?.toISOString(), + dozaMsv: o.dozaMsv?.toString(), + })), + })), + batchId, + dto.tip, + deptName, + dto.documentContext, + ); + + // Create MedicalCheckup for each employee with documents + for (const emp of groupEmployees) { + const checkup = await this.prisma.medicalCheckup.create({ + data: { + employeeId: emp.id, + tip: dto.tip, + dataPlanificata: new Date(dto.dataPlanificata), + documenteGenerate: [...groupDocs, perEmployee[emp.id]], + }, + }); + createdCheckups.push({ + employeeId: emp.id, + checkupId: checkup.id, + documents: [...groupDocs, perEmployee[emp.id]], + }); + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'MedicalCheckup', entityId: checkup.id }); + } + } + + return { + batchId, + groupsCount: groups.size, + employeesCount: employees.length, + checkups: createdCheckups, + }; + } + + /** Inbox: employees whose medical control is overdue or due in next 30 days */ + async upcomingExpirations() { + const now = new Date(); + const cutoff = new Date(now); + cutoff.setFullYear(cutoff.getFullYear() - 1); + cutoff.setDate(cutoff.getDate() + 30); + + return this.prisma.employeeMedicalProfile.findMany({ + where: { + OR: [ + { dataUltimControlMedical: null }, + { dataUltimControlMedical: { lte: cutoff } }, + ], + employee: { status: 'activ' }, + }, + include: { + employee: { + select: { + id: true, idnp: true, nume: true, prenume: true, + contracts: { + where: { dataDemisiei: null }, + select: { department: { select: { name: true } } }, + take: 1, + }, + }, + }, + workplaceRiskCard: { select: { id: true, name: true } }, + }, + orderBy: { dataUltimControlMedical: 'asc' }, + }); + } +} diff --git a/apps/api/src/modules/medical/services/checkup.service.ts b/apps/api/src/modules/medical/services/checkup.service.ts new file mode 100644 index 0000000..01e3790 --- /dev/null +++ b/apps/api/src/modules/medical/services/checkup.service.ts @@ -0,0 +1,218 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { DocumentGeneratorService } from './document-generator.service'; +import { StorageService } from './storage.service'; +import { CompleteCheckupDto, CreateCheckupDto } from '../dto/checkup.dto'; +import { MedicalCheckupType } from '@prisma/client'; + +@Injectable() +export class CheckupService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly docs: DocumentGeneratorService, + private readonly storage: StorageService, + ) {} + + findByEmployee(employeeId: string) { + return this.prisma.medicalCheckup.findMany({ + where: { employeeId }, + orderBy: { dataPlanificata: 'desc' }, + }); + } + + async findOne(id: string) { + const c = await this.prisma.medicalCheckup.findUnique({ + where: { id }, + include: { + employee: { + include: { + medicalProfile: { include: { workplaceRiskCard: true } }, + contracts: { + where: { dataDemisiei: null }, + include: { department: true }, + take: 1, + }, + }, + }, + }, + }); + if (!c) throw new NotFoundException(); + return c; + } + + async create(employeeId: string, dto: CreateCheckupDto, userId: string, role: string) { + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + const checkup = await this.prisma.medicalCheckup.create({ + data: { + employeeId, + tip: dto.tip, + dataPlanificata: new Date(dto.dataPlanificata), + }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'MedicalCheckup', entityId: checkup.id }); + return checkup; + } + + // medic_familie completes the checkup with verdict + recommendations + async complete(id: string, dto: CompleteCheckupDto, userId: string, role: string) { + const existing = await this.prisma.medicalCheckup.findUniqueOrThrow({ + where: { id }, + include: { + employee: { + include: { + medicalProfile: { include: { workplaceRiskCard: true } }, + contracts: { + where: { dataDemisiei: null }, + include: { department: true }, + take: 1, + }, + }, + }, + }, + }); + + const checkup = await this.prisma.medicalCheckup.update({ + where: { id }, + data: { + verdict: dto.verdict, + dataEfectuata: new Date(dto.dataEfectuata), + recomandari: dto.recomandari, + valabilPanaLa: dto.valabilPanaLa ? new Date(dto.valabilPanaLa) : null, + semnatDe: dto.semnatDe, + }, + }); + + // Sync dataUltimControlMedical on the employee profile + const updateProfile = ([ + MedicalCheckupType.la_angajare, + MedicalCheckupType.periodic, + MedicalCheckupType.la_reluarea_activitatii, + ] as MedicalCheckupType[]).includes(checkup.tip); + + if (updateProfile) { + await this.prisma.employeeMedicalProfile.updateMany({ + where: { employeeId: checkup.employeeId }, + data: { dataUltimControlMedical: new Date(dto.dataEfectuata) }, + }); + } + + // Generate completed Anexa 6 if risk card is available + const profile = existing.employee.medicalProfile; + if (profile?.workplaceRiskCard) { + const anex6 = await this.docs.generateAnex6Completed( + { + id: existing.employee.id, + idnp: existing.employee.idnp, + nume: existing.employee.nume, + prenume: existing.employee.prenume, + dataNasterii: existing.employee.dataNasterii.toISOString(), + ocupatieCorm: profile.ocupatieCorm, + expusRadiatiiIonizante: profile.expusRadiatiiIonizante, + departmentName: existing.employee.contracts[0]?.department.name, + }, + { + id: profile.workplaceRiskCard.id, + name: profile.workplaceRiskCard.name, + riskFactors: profile.workplaceRiskCard.riskFactors as Record<string, string[]>, + filiala: profile.workplaceRiskCard.filiala, + adresaFiliala: profile.workplaceRiskCard.adresaFiliala, + telefonFiliala: profile.workplaceRiskCard.telefonFiliala, + caemPrimeleDouaCifre: profile.workplaceRiskCard.caemPrimeleDouaCifre, + cormSubgrupaMajora: profile.workplaceRiskCard.cormSubgrupaMajora, + directiaSectiaSectorul: profile.workplaceRiskCard.directiaSectiaSectorul, + numarulLoculuiDeMunca: profile.workplaceRiskCard.numarulLoculuiDeMunca, + caemDiviziune: profile.workplaceRiskCard.caemDiviziune, + clasaConditiilorDeMunca: profile.workplaceRiskCard.clasaConditiilorDeMunca, + }, + checkup.tip, + dto.verdict, + dto.recomandari, + dto.dataEfectuata, + dto.valabilPanaLa, + dto.semnatDe, + id, + ); + + // Append generated Anexa 6 to existing documents + const existingDocs = (existing.documenteGenerate as { name: string; url: string; type: string }[] | null) ?? []; + await this.prisma.medicalCheckup.update({ + where: { id }, + data: { documenteGenerate: [...existingDocs, anex6] }, + }); + } + + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'MedicalCheckup', entityId: id }); + return checkup; + } + + async deleteDocument(checkupId: string, docName: string, userId: string, role: string): Promise<void> { + const checkup = await this.prisma.medicalCheckup.findUnique({ where: { id: checkupId } }); + if (!checkup) throw new NotFoundException(); + + const docs = (checkup.documenteGenerate as { name: string; url: string; type: string }[] | null) ?? []; + const target = docs.find((d) => d.name === docName); + if (!target) throw new NotFoundException('Document not found'); + + // Delete from MinIO — extract key from s3://bucket/key + const key = target.url.replace(/^s3:\/\/[^/]+\//, ''); + try { await this.storage.remove(key); } catch { /* ignore if already gone */ } + + const updated = docs.filter((d) => d.name !== docName); + await this.prisma.medicalCheckup.update({ + where: { id: checkupId }, + data: { documenteGenerate: updated }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'MedicalDocument', entityId: checkupId }); + } + + async remove(checkupId: string, userId: string, role: string): Promise<void> { + const checkup = await this.prisma.medicalCheckup.findUnique({ where: { id: checkupId } }); + if (!checkup) throw new NotFoundException(); + + const docs = (checkup.documenteGenerate as { name: string; url: string; type: string }[] | null) ?? []; + await Promise.all(docs.map(async (d) => { + const key = d.url.replace(/^s3:\/\/[^/]+\//, ''); + try { await this.storage.remove(key); } catch { /* ignore */ } + })); + + await this.prisma.medicalCheckup.delete({ where: { id: checkupId } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'MedicalCheckup', entityId: checkupId }); + } + + async deleteAllDocuments(checkupId: string, userId: string, role: string): Promise<void> { + const checkup = await this.prisma.medicalCheckup.findUnique({ where: { id: checkupId } }); + if (!checkup) throw new NotFoundException(); + + const docs = (checkup.documenteGenerate as { name: string; url: string; type: string }[] | null) ?? []; + if (!docs.length) return; + + await Promise.all(docs.map(async (d) => { + const key = d.url.replace(/^s3:\/\/[^/]+\//, ''); + try { await this.storage.remove(key); } catch { /* ignore if already gone */ } + })); + + await this.prisma.medicalCheckup.update({ + where: { id: checkupId }, + data: { documenteGenerate: [] }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'MedicalDocumentBatch', entityId: checkupId }); + } + + // Pending checkups for medic_familie inbox + findPendingForMedic() { + return this.prisma.medicalCheckup.findMany({ + where: { verdict: null }, + orderBy: { dataPlanificata: 'asc' }, + include: { + employee: { + select: { + id: true, idnp: true, nume: true, prenume: true, sex: true, dataNasterii: true, + medicalProfile: { include: { workplaceRiskCard: { select: { name: true } } } }, + }, + }, + }, + }); + } +} diff --git a/apps/api/src/modules/medical/services/document-generator.service.ts b/apps/api/src/modules/medical/services/document-generator.service.ts new file mode 100644 index 0000000..d99e305 --- /dev/null +++ b/apps/api/src/modules/medical/services/document-generator.service.ts @@ -0,0 +1,627 @@ +import { Injectable } from '@nestjs/common'; +import { Document, Packer } from 'docx'; +import { AnexaType } from '@prisma/client'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { StorageService } from './storage.service'; +import { DocxTemplateService } from './docx-template.service'; +import { tiptapToDocx, TemplateVars } from './tiptap-to-docx'; + +export type GeneratedDoc = { name: string; url: string; type: string }; +export type DocumentContext = { + telefon?: string; + fax?: string; + email?: string; + solicitant?: string; + functia?: string; +}; + +const COMPANY = 'Medpark International Hospital'; +const COMPANY_IDNO = '1003600035476'; +const COMPANY_ADDR = 'str. Nicolae Testemițanu 29, Chișinău, MD-2025'; + +const markCb = (b: unknown) => (b ? '☑' : '☐'); + +// evaluareDetalii (cheie internă) → placeholder checkbox din șablonul .docx (vezi templates/docx/README.md) +const CB_MAP: Record<string, string> = { + echipa: 'cbEchipa', schimbNoapte: 'cbSchimbNoapte', pauzeOrganizate: 'cbPauze', + riscInfectare: 'cbInfectare', riscElectrocutare: 'cbElectrocutare', riscTensiuneInalta: 'cbTensiuneInalta', + riscInecare: 'cbInecare', riscAsfixiere: 'cbAsfixiere', riscStrivire: 'cbStrivire', riscTaiere: 'cbTaiere', + riscIntepare: 'cbIntepare', riscLovire: 'cbLovire', riscMuscatura: 'cbMuscatura', riscMicrotraumatisme: 'cbMicrotraumatisme', + conduceMasina: 'cbConduceMasina', conduceUtilajeIntrauzinal: 'cbUtilajeIntrauzinal', + suprafataVerticala: 'cbSuprafVerticala', suprafataOrizontala: 'cbSuprafOrizontala', suprafataOblica: 'cbSuprafOblica', + muncaIzolare: 'cbMuncaIzolare', muncaInaltime: 'cbMuncaInaltime', muncaInMiscare: 'cbMuncaMiscare', + pozitieOrtostatica: 'cbPozitieOrtostatica', pozitieAsezat: 'cbPozitieAsezat', pozitieAplecata: 'cbPozitieAplecata', + pozitieMixta: 'cbPozitieMixta', pozitieFortata: 'cbPozitieFortata', + coloanaCervicala: 'cbColoanaCervicala', coloanaToracala: 'cbColoanaToracala', coloanaLombara: 'cbColoanaLombara', + manipulareRidicare: 'cbManipRidicare', manipulareCoborare: 'cbManipCoborare', manipulareImpingere: 'cbManipImpingere', + manipulareTragere: 'cbManipTragere', manipularePurtare: 'cbManipPurtare', manipulareDeplasare: 'cbManipDeplasare', + suprasolicitariVizuale: 'cbVizuale', suprasolicitariAuditive: 'cbAuditive', suprasolicitariNeuropsihice: 'cbNeuropsihice', + microclimatInterior: 'cbMicroclimatInterior', microclimatExterior: 'cbMicroclimatExterior', + radiatiiCaloriceRece: 'cbCaloriceRece', radiatiiCaloriceCalda: 'cbCaloriceCalda', + iluminatSuficient: 'cbIluminatSuficient', iluminatInsuficient: 'cbIluminatInsuficient', iluminatNatural: 'cbIluminatNatural', + iluminatArtificial: 'cbIluminatArtificial', iluminatMixt: 'cbIluminatMixt', + lucruMonitor: 'cbLucruMonitor', platformeDigitale: 'cbPlatformeDigitale', deplasari: 'cbDeplasari', +}; +const TEXT_MAP: Record<string, string> = { + oreZi: 'oreZi', schimburi: 'schimburi', conduceMasinaCategorie: 'categorieConducere', + spatiuL: 'spatiuL', spatiul: 'spatiul', spatiuH: 'spatiuH', greutateMaxima: 'greutateMaxima', + operatiuni: 'operatiuni', deplasariDescriere: 'deplasariDescriere', alteRiscuri: 'alteRiscuri', +}; +const ANEXE_MAP: Record<string, string> = { + vestiar: 'cbVestiar', chiuveta: 'cbChiuveta', wc: 'cbWc', dus: 'cbDus', salaMese: 'cbSalaMese', recreere: 'cbRecreere', +}; +const TIP_TO_KEY: Record<string, string> = { + AGENT_CHIMIC: 'chimici', PULBERI: 'pulberi', AGENT_BIOLOGIC: 'biologici', + ZGOMOT: 'zgomot', VIBRATII: 'vibratii', CAMP_ELECTROMAGNETIC: 'campEM', RADIATII_OPTICE: 'optice', +}; + +interface EmployeeForDoc { + id: string; + idnp: string; + nume: string; + prenume: string; + dataNasterii?: string; + ocupatieCorm?: string | null; + expusRadiatiiIonizante: boolean; + dataIntrarii?: string | null; + expunereAnterioaraPerioda?: string | null; + expunereAnterioaraAni?: number | null; + dozaCumulataExternaMsv?: string | null; + dozaCumulataInternaMsv?: string | null; + departmentName?: string; + overexposures?: { fel: string; tipExpunere?: string | null; data?: string | null; dozaMsv?: string | number | null }[]; +} + +interface RiskExposureData { + tip: string; + denumire: string; + cas?: string | null; + einecs?: string | null; + clasificare?: string | null; + zonaAfectata?: string | null; + timpExpunere?: string | null; + vep?: string | null; + vlep?: string | null; + caracteristici?: string | null; +} + +interface RiskCardData { + id: string; + name: string; + riskFactors?: unknown; + // Antet Anexa 4 + filiala?: string | null; + adresaFiliala?: string | null; + telefonFiliala?: string | null; + caemPrimeleDouaCifre?: string | null; + cormSubgrupaMajora?: string | null; + directiaSectiaSectorul?: string | null; + numarulLoculuiDeMunca?: string | null; + caemDiviziune?: string | null; + clasaConditiilorDeMunca?: string | null; + numarLucratoriPosibili?: number | null; + tipFisa?: string | null; // STANDARD | DISTANTA_DIGITAL + // Bloc descriptiv + subsol + evaluareDetalii?: Record<string, unknown> | null; + anexeIgienicoSanitare?: Record<string, unknown> | null; + mijloaceProtectieColectiva?: string | null; + mijloaceProtectieIndividuala?: string | null; + echipamentLucru?: string | null; + observatii?: string | null; + // Radiații ionizante (per loc de muncă) + radiatiiIonizante?: boolean | null; + radiatiiGrupa?: string | null; + radiatiiAparatura?: string | null; + radiatiiSurse?: string | null; + radiatiiTipExpunere?: string | null; + radiatiiMasuriProtectie?: string | null; + // Tabele factoriale + exposures?: RiskExposureData[]; +} + +const TIP_LABELS: Record<string, string> = { + la_angajare: 'Examen medical la angajarea în muncă', + periodic: 'Examen medical periodic', + la_reluarea_activitatii: 'Examen medical la reluarea activității', + la_incetarea_expunerii: 'Examen medical la încetarea expunerii profesionale', + suplimentar: 'Suplimentar (la solicitare)', +}; + +function fmtDateRo(s: string | Date | null | undefined): string { + if (!s) return '—'; + const d = s instanceof Date ? s : new Date(s); + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +@Injectable() +export class DocumentGeneratorService { + constructor( + private readonly storage: StorageService, + private readonly prisma: PrismaService, + private readonly docx: DocxTemplateService, + ) {} + + private async packBuffer(buffer: Buffer, key: string, name: string): Promise<GeneratedDoc> { + const url = await this.storage.upload( + `${key}.docx`, + buffer, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + return { name: `${name}.docx`, url, type: 'docx' }; + } + + private async pack(doc: Document, key: string, name: string): Promise<GeneratedDoc> { + return this.packBuffer(await Packer.toBuffer(doc), key, name); + } + + private async loadTemplate(type: AnexaType) { + const template = await this.prisma.anexaTemplate.findUnique({ where: { type } }); + if (!template) throw new Error(`Șablonul ${type} nu a fost seeded. Rulați pnpm db:seed.`); + return template.contentJson as { type: string; content: unknown[] }; + } + + /** + * Build a template-vars map for a single employee within an Anexa context. + * Group-level fields (riskCard, tipExamen, departmentName, row index) are + * injected via the optional `ctx` parameter so the same helper works for + * group documents (Anexa 3/4/4B) and per-employee Anexa 6. + */ + private buildVars( + employee: EmployeeForDoc, + ctx?: { + riskCard?: RiskCardData; + tipExamen?: string; + departmentName?: string; + rowIndex?: number; + verdict?: string; + recomandari?: string; + }, + ): TemplateVars { + const dob = employee.dataNasterii ? new Date(employee.dataNasterii) : null; + const externa = employee.dozaCumulataExternaMsv; + const interna = employee.dozaCumulataInternaMsv; + const totala = externa != null && interna != null + ? (Number(externa) + Number(interna)).toFixed(4) + : '—'; + + const verdictKey = ctx?.verdict ? this.matchVerdictKey(ctx.verdict) : null; + const cb = (key: string) => (verdictKey === key ? '☑' : '☐'); + + return { + // Company + 'company.name': COMPANY, + 'company.idno': COMPANY_IDNO, + 'company.address': COMPANY_ADDR, + + // Document meta + 'document.date': fmtDateRo(new Date()), + 'document.number': '001', + + // Employee + 'employee.lastName': employee.nume, + 'employee.firstName': employee.prenume, + 'employee.fullName': `${employee.nume} ${employee.prenume}`, + 'employee.idnp': employee.idnp, + 'employee.birthYear': dob ? String(dob.getFullYear()) : '—', + 'employee.birthDate': fmtDateRo(employee.dataNasterii), + 'employee.occupation': employee.ocupatieCorm ?? '—', + 'employee.department': employee.departmentName ?? ctx?.departmentName ?? '—', + + // Group / context + 'tipExamen': ctx?.tipExamen ?? '—', + 'riskCard.name': ctx?.riskCard?.name ?? '—', + 'department.name': ctx?.departmentName ?? employee.departmentName ?? '—', + + // Row (used inside table loops in seed templates) + 'row.index': String(ctx?.rowIndex ?? 1), + 'row.seatNumber': String(ctx?.rowIndex ?? 1), + 'row.employeeName': `${employee.prenume} ${employee.nume}`, + 'row.idnp': employee.idnp, + 'row.birthYear': dob ? String(dob.getFullYear()) : '—', + 'row.occupation': employee.ocupatieCorm ?? '—', + 'row.tipExamen': ctx?.tipExamen ?? '—', + 'row.riskFactors': ctx?.riskCard?.name ?? '—', + + // Radiation + 'radiation.exposed': employee.expusRadiatiiIonizante ? 'DA' : 'Nu', + 'radiation.entryDate': fmtDateRo(employee.dataIntrarii), + 'radiation.priorPeriod': employee.expunereAnterioaraPerioda ?? '—', + 'radiation.priorYears': employee.expunereAnterioaraAni != null ? String(employee.expunereAnterioaraAni) : '—', + 'radiation.externalMsv': externa != null ? Number(externa).toFixed(4) : '0.0000', + 'radiation.internalMsv': interna != null ? Number(interna).toFixed(4) : '0.0000', + 'radiation.totalMsv': totala, + + // Verdict (Anexa 6) + 'verdict.label': ctx?.verdict ?? '—', + 'verdict.recomandari': ctx?.recomandari ?? '—', + 'verdict.checkbox.apt': cb('apt'), + 'verdict.checkbox.apt_perioada_adaptare': cb('apt_perioada_adaptare'), + 'verdict.checkbox.apt_conditionat': cb('apt_conditionat'), + 'verdict.checkbox.inapt_temporar': cb('inapt_temporar'), + 'verdict.checkbox.inapt': cb('inapt'), + }; + } + + /** Normalize verdict (either raw key or human label) back to the canonical key. */ + private matchVerdictKey(verdict: string): string | null { + const labelToKey: Record<string, string> = { + apt: 'apt', + 'Apt': 'apt', + apt_perioada_adaptare: 'apt_perioada_adaptare', + 'Apt în perioada de adaptare': 'apt_perioada_adaptare', + apt_conditionat: 'apt_conditionat', + 'Apt condiționat': 'apt_conditionat', + inapt_temporar: 'inapt_temporar', + 'Inapt temporar': 'inapt_temporar', + inapt: 'inapt', + 'Inapt': 'inapt', + }; + return labelToKey[verdict] ?? null; + } + + private async renderAnexa( + type: AnexaType, + employee: EmployeeForDoc, + ctx?: Parameters<DocumentGeneratorService['buildVars']>[1], + rows?: Record<string, string>[], + extra?: { rowSets?: Record<string, Record<string, string>[]>; extraVars?: TemplateVars }, + ): Promise<Document> { + const template = await this.loadTemplate(type); + const vars = { ...this.buildVars(employee, ctx), ...(extra?.extraVars ?? {}) }; + const children = tiptapToDocx(template as never, vars, { rows, rowSets: extra?.rowSets }); + return new Document({ sections: [{ children }] }); + } + + /** + * Builds Anexa 4 ("Fișa de evaluare a riscurilor profesionale") variables and + * factor-table row-sets from a workplace risk card. The card is per-workplace, + * so Anexa 4 carries NO employee list (that belongs to Anexa 3). + */ + private buildAnexa4(card: RiskCardData): { vars: TemplateVars; rowSets: Record<string, Record<string, string>[]> } { + const mark = (b: unknown) => (b ? '☑' : '☐'); + const vars: TemplateVars = { + 'a4.unitatea': COMPANY, + 'a4.adresa': COMPANY_ADDR, + 'a4.filiala': card.filiala ?? '—', + 'a4.adresaFiliala': card.adresaFiliala ?? '—', + 'a4.caem2': card.caemPrimeleDouaCifre ?? '—', + 'a4.cormSubgrupa': card.cormSubgrupaMajora ?? '—', + 'a4.directiaSectia': card.directiaSectiaSectorul ?? '—', + 'a4.numarLoc': card.numarulLoculuiDeMunca ?? '—', + 'a4.caemDiviziune': card.caemDiviziune ?? '—', + 'a4.numarLucratori': card.numarLucratoriPosibili != null ? String(card.numarLucratoriPosibili) : '—', + 'a4.clasa': card.clasaConditiilorDeMunca ?? '—', + 'a4.protectieColectiva': card.mijloaceProtectieColectiva ?? '—', + 'a4.protectieIndividuala': card.mijloaceProtectieIndividuala ?? '—', + 'a4.echipament': card.echipamentLucru ?? '—', + 'a4.observatii': card.observatii ?? '—', + 'a4.rad.grupa': card.radiatiiGrupa ?? '—', + 'a4.rad.aparatura': card.radiatiiAparatura ?? '—', + 'a4.rad.surse': card.radiatiiSurse ?? '—', + 'a4.rad.tipExpunere': card.radiatiiTipExpunere ?? '—', + 'a4.rad.masuriProtectie': card.radiatiiMasuriProtectie ?? '—', + 'a4.cb.radiatii_da': mark(card.radiatiiIonizante), + 'a4.cb.radiatii_nu': mark(!card.radiatiiIonizante), + }; + + // Descriptive block: booleans → checkbox chips, other values → text chips. + for (const [k, v] of Object.entries(card.evaluareDetalii ?? {})) { + if (typeof v === 'boolean') vars[`a4.cb.${k}`] = mark(v); + else if (v != null) vars[`a4.val.${k}`] = String(v); + } + for (const [k, v] of Object.entries(card.anexeIgienicoSanitare ?? {})) { + vars[`a4.cb.anexe.${k}`] = mark(v); + } + + // Factor tables grouped by exposure type + da/nu flags. + const TIP_TO_KEY: Record<string, string> = { + AGENT_CHIMIC: 'chimici', PULBERI: 'pulberi', AGENT_BIOLOGIC: 'biologici', + ZGOMOT: 'zgomot', VIBRATII: 'vibratii', CAMP_ELECTROMAGNETIC: 'campEM', RADIATII_OPTICE: 'optice', + }; + const rowSets: Record<string, Record<string, string>[]> = {}; + for (const key of Object.values(TIP_TO_KEY)) rowSets[key] = []; + for (const e of card.exposures ?? []) { + const key = TIP_TO_KEY[e.tip]; + if (!key) continue; + rowSets[key].push({ + 'row.denumire': e.denumire ?? '—', + 'row.cas': e.cas ?? '—', + 'row.einecs': e.einecs ?? '—', + 'row.clasificare': e.clasificare ?? '—', + 'row.zona': e.zonaAfectata ?? '—', + 'row.timp': e.timpExpunere ?? '—', + 'row.vep': e.vep ?? '—', + 'row.vlep': e.vlep ?? '—', + 'row.caracteristici': e.caracteristici ?? '—', + }); + } + for (const [key, rows] of Object.entries(rowSets)) { + vars[`a4.cb.${key}_da`] = mark(rows.length > 0); + vars[`a4.cb.${key}_nu`] = mark(rows.length === 0); + } + + return { vars, rowSets }; + } + + // ─── docxtemplater data builders (placeholder-uri din templates/docx/README.md) ─── + + private dataAnexa3( + card: RiskCardData, + employees: EmployeeForDoc[], + tipLabel: string, + documentContext?: DocumentContext, + ): Record<string, unknown> { + const factorRisc = (card.exposures ?? []).map((e) => e.denumire).join(', '); + return { + unitatea: COMPANY, idno: COMPANY_IDNO, adresa: COMPANY_ADDR, + telefon: documentContext?.telefon ?? '', fax: documentContext?.fax ?? '', email: documentContext?.email ?? '', + filiala: card.filiala ?? '', adresaFiliala: card.adresaFiliala ?? '', telefonFiliala: card.telefonFiliala ?? '', + dataCompletarii: fmtDateRo(new Date()), solicitant: documentContext?.solicitant ?? '', functia: documentContext?.functia ?? '', + angajati: employees.map((e, i) => ({ + nr: String(i + 1), + numePrenume: `${e.nume} ${e.prenume}`, + anNastere: e.dataNasterii ? String(new Date(e.dataNasterii).getFullYear()) : '', + idnp: e.idnp, + tipExamen: tipLabel, + ocupatieCorm: e.ocupatieCorm ?? '', + caem: card.caemDiviziune ?? '', + numarLoc: card.numarulLoculuiDeMunca ?? '', + factorRisc, + })), + }; + } + + private dataAnexa4(card: RiskCardData, documentContext?: DocumentContext): Record<string, unknown> { + const data: Record<string, unknown> = { + unitatea: COMPANY, idno: COMPANY_IDNO, adresa: COMPANY_ADDR, + telefon: documentContext?.telefon ?? '', fax: documentContext?.fax ?? '', email: documentContext?.email ?? '', + filiala: card.filiala ?? '', adresaFiliala: card.adresaFiliala ?? '', telefonFiliala: card.telefonFiliala ?? '', + caem2: card.caemPrimeleDouaCifre ?? '', + cormSubgrupa: card.cormSubgrupaMajora ?? '', directiaSectia: card.directiaSectiaSectorul ?? '', + numarLoc: card.numarulLoculuiDeMunca ?? '', caemDiviziune: card.caemDiviziune ?? '', + numarLucratori: card.numarLucratoriPosibili != null ? String(card.numarLucratoriPosibili) : '', + clasa: card.clasaConditiilorDeMunca ?? '', + protectieColectiva: card.mijloaceProtectieColectiva ?? '', protectieIndividuala: card.mijloaceProtectieIndividuala ?? '', + echipament: card.echipamentLucru ?? '', observatii: card.observatii ?? '', + radGrupa: card.radiatiiGrupa ?? '', radSurse: card.radiatiiSurse ?? '', radTipExpunere: card.radiatiiTipExpunere ?? '', + radAparatura: card.radiatiiAparatura ?? '', radMasuri: card.radiatiiMasuriProtectie ?? '', + cbRadiatii: markCb(card.radiatiiIonizante), + cbRadiatiiNu: markCb(!card.radiatiiIonizante), + // text fields default + oreZi: '', schimburi: '', categorieConducere: '', spatiuL: '', spatiul: '', spatiuH: '', greutateMaxima: '', + operatiuni: '', deplasariDescriere: '', alteRiscuri: '', + dataCompletarii: fmtDateRo(new Date()), + }; + for (const cbName of Object.values(CB_MAP)) data[cbName] = '☐'; + for (const cbName of Object.values(ANEXE_MAP)) data[cbName] = '☐'; + for (const [k, v] of Object.entries((card.evaluareDetalii ?? {}) as Record<string, unknown>)) { + if (CB_MAP[k]) data[CB_MAP[k]] = markCb(v); + else if (TEXT_MAP[k]) data[TEXT_MAP[k]] = v != null ? String(v) : ''; + } + for (const [k, v] of Object.entries((card.anexeIgienicoSanitare ?? {}) as Record<string, unknown>)) { + if (ANEXE_MAP[k]) data[ANEXE_MAP[k]] = markCb(v); + } + const loops: Record<string, unknown[]> = { chimici: [], pulberi: [], biologici: [], zgomot: [], vibratii: [], campEM: [], optice: [] }; + for (const e of card.exposures ?? []) { + const key = TIP_TO_KEY[e.tip]; + if (!key) continue; + loops[key].push({ + denumire: e.denumire ?? '', cas: e.cas ?? '', einecs: e.einecs ?? '', clasificare: e.clasificare ?? '', + note: e.caracteristici ?? '', zona: e.zonaAfectata ?? '', timp: e.timpExpunere ?? '', + vep: e.vep ?? '', vlep: e.vlep ?? '', caracteristici: e.caracteristici ?? '', + }); + } + Object.assign(data, loops); + const groupFlags: Record<string, string> = { + chimici: 'Chimici', + pulberi: 'Pulberi', + biologici: 'Biologici', + zgomot: 'Zgomot', + vibratii: 'Vibratii', + campEM: 'CampEM', + optice: 'Optice', + }; + for (const [key, suffix] of Object.entries(groupFlags)) { + const rows = loops[key] ?? []; + data[`cb${suffix}`] = markCb(rows.length > 0); + data[`cb${suffix}Nu`] = markCb(rows.length === 0); + } + return data; + } + + private dataAnexa4B(emp: EmployeeForDoc, card: RiskCardData, documentContext?: DocumentContext): Record<string, unknown> { + const ext = emp.dozaCumulataExternaMsv; + const int = emp.dozaCumulataInternaMsv; + const mapOv = (o: NonNullable<EmployeeForDoc['overexposures']>[number]) => ({ + tipExpunere: o.tipExpunere ?? '', + data: o.data ? fmtDateRo(o.data) : '', + doza: o.dozaMsv != null ? String(o.dozaMsv) : '', + }); + const ov = emp.overexposures ?? []; + return { + unitatea: COMPANY, idno: COMPANY_IDNO, adresa: COMPANY_ADDR, + telefon: documentContext?.telefon ?? '', fax: documentContext?.fax ?? '', email: documentContext?.email ?? '', + filiala: card.filiala ?? '', adresaFiliala: card.adresaFiliala ?? '', telefonFiliala: card.telefonFiliala ?? '', + caem2: card.caemPrimeleDouaCifre ?? '', + cormSubgrupa: card.cormSubgrupaMajora ?? '', directiaSectia: card.directiaSectiaSectorul ?? '', + numarLoc: card.numarulLoculuiDeMunca ?? '', caemDiviziune: card.caemDiviziune ?? '', + numePrenume: `${emp.nume} ${emp.prenume}`, idnp: emp.idnp, + cbRadiatii: markCb(emp.expusRadiatiiIonizante), + dataIntrarii: emp.dataIntrarii ? fmtDateRo(emp.dataIntrarii) : '', + expAnterioaraPerioada: emp.expunereAnterioaraPerioda ?? '', + expAnterioaraAni: emp.expunereAnterioaraAni != null ? String(emp.expunereAnterioaraAni) : '', + dozaExterna: ext != null ? Number(ext).toFixed(4) : '', + dozaInterna: int != null ? Number(int).toFixed(4) : '', + dozaTotala: ext != null && int != null ? (Number(ext) + Number(int)).toFixed(4) : '', + supraexpExceptionale: ov.filter((o) => o.fel === 'EXCEPTIONALA').map(mapOv), + supraexpAccidentale: ov.filter((o) => o.fel === 'ACCIDENTALA').map(mapOv), + dataCompletarii: fmtDateRo(new Date()), + }; + } + + private dataAnexa6( + emp: EmployeeForDoc, + tipLabel: string, + options?: { + riskCard?: RiskCardData; + verdict?: string; + recomandari?: string; + dataCompletarii?: string | Date; + valabilPanaLa?: string | Date; + semnatDe?: string; + }, + ): Record<string, unknown> { + const key = options?.verdict ? this.matchVerdictKey(options.verdict) : null; + const cbv = (k: string) => (key === k ? '☑' : '☐'); + return { + unitatea: COMPANY, adresa: COMPANY_ADDR, + numePrenume: `${emp.nume} ${emp.prenume}`, idnp: emp.idnp, + anNastere: emp.dataNasterii ? String(new Date(emp.dataNasterii).getFullYear()) : '', + ocupatieCorm: emp.ocupatieCorm ?? '', departament: emp.departmentName ?? '', + caemDiviziune: options?.riskCard?.caemDiviziune ?? '', numarLoc: options?.riskCard?.numarulLoculuiDeMunca ?? '', + factorRisc: options?.riskCard?.name ?? '', + tipExamen: tipLabel, dataCompletarii: fmtDateRo(options?.dataCompletarii ?? new Date()), + cbApt: cbv('apt'), cbAptAdaptare: cbv('apt_perioada_adaptare'), cbAptConditionat: cbv('apt_conditionat'), + cbInaptTemporar: cbv('inapt_temporar'), cbInapt: cbv('inapt'), + recomandari: options?.recomandari ?? '', valabilPanaLa: options?.valabilPanaLa ? fmtDateRo(options.valabilPanaLa) : '', + semnatDe: options?.semnatDe ?? '', + }; + } + + private buildRowVars(employees: EmployeeForDoc[], tipLabel: string): Record<string, string>[] { + return employees.map((emp, idx) => { + const dob = emp.dataNasterii ? new Date(emp.dataNasterii) : null; + return { + 'row.index': String(idx + 1), + 'row.seatNumber': String(idx + 1), + 'row.employeeName': `${emp.prenume} ${emp.nume}`, + 'row.idnp': emp.idnp, + 'row.birthYear': dob ? String(dob.getFullYear()) : '—', + 'row.birthDate': fmtDateRo(emp.dataNasterii), + 'row.occupation': emp.ocupatieCorm ?? '—', + 'row.tipExamen': tipLabel, + 'row.department': emp.departmentName ?? '—', + 'row.radiationExposed': emp.expusRadiatiiIonizante ? 'DA' : 'Nu', + 'row.entryDate': fmtDateRo(emp.dataIntrarii), + 'row.priorPeriod': emp.expunereAnterioaraPerioda ?? '—', + 'row.priorYears': emp.expunereAnterioaraAni != null ? String(emp.expunereAnterioaraAni) : '—', + 'row.externalMsv': emp.dozaCumulataExternaMsv != null ? Number(emp.dozaCumulataExternaMsv).toFixed(4) : '0.0000', + 'row.internalMsv': emp.dozaCumulataInternaMsv != null ? Number(emp.dozaCumulataInternaMsv).toFixed(4) : '0.0000', + 'row.totalMsv': emp.dozaCumulataExternaMsv != null && emp.dozaCumulataInternaMsv != null + ? (Number(emp.dozaCumulataExternaMsv) + Number(emp.dozaCumulataInternaMsv)).toFixed(4) + : '—', + }; + }); + } + + async generateForGroup( + riskCard: RiskCardData, + employees: EmployeeForDoc[], + checkupBatchId: string, + tipExamen: string, + departmentName?: string, + documentContext?: DocumentContext, + ): Promise<{ groupDocs: GeneratedDoc[]; perEmployee: Record<string, GeneratedDoc> }> { + const groupDocs: GeneratedDoc[] = []; + const base = `medical/${checkupBatchId}/${riskCard.id}`; + const tipLabel = TIP_LABELS[tipExamen] ?? tipExamen; + + const primary = employees[0]; + const ctx = { + riskCard, + tipExamen: tipLabel, + departmentName, + rowIndex: 1, + }; + const allRows = this.buildRowVars(employees, tipLabel); + + // 1. Anexa 3 — Fișa de solicitare (group) + const buf3 = this.docx.has('ANEXA_3') + ? this.docx.render('ANEXA_3', this.dataAnexa3(riskCard, employees, tipLabel, documentContext)) + : await Packer.toBuffer(await this.renderAnexa('ANEXA_3', primary, ctx, allRows)); + groupDocs.push(await this.packBuffer(buf3, `${base}/anex3_fisa_solicitare`, 'Anexa_3_Fisa_Solicitare')); + + // 2. Anexa 4 (sau 4A pentru muncă la distanță) — per loc de muncă, fără listă de angajați + const a4type: AnexaType = riskCard.tipFisa === 'DISTANTA_DIGITAL' ? 'ANEXA_4A' : 'ANEXA_4'; + let buf4: Buffer; + if (this.docx.has(a4type)) { + buf4 = this.docx.render(a4type, this.dataAnexa4(riskCard, documentContext)); + } else { + const a4 = this.buildAnexa4(riskCard); + buf4 = await Packer.toBuffer(await this.renderAnexa('ANEXA_4', primary, ctx, undefined, { rowSets: a4.rowSets, extraVars: a4.vars })); + } + const a4name = a4type === 'ANEXA_4A' ? 'Anexa_4A_Fisa_Evaluare' : 'Anexa_4_Fisa_Evaluare'; + groupDocs.push(await this.packBuffer(buf4, `${base}/anex4_fisa_evaluare`, a4name)); + + // 3. Anexa 4B — supliment radiații (per lucrător expus, conform regulamentului) + const radiationEmployees = employees.filter((e) => e.expusRadiatiiIonizante); + if (radiationEmployees.length > 0) { + if (this.docx.has('ANEXA_4B')) { + for (const emp of radiationEmployees) { + const buf = this.docx.render('ANEXA_4B', this.dataAnexa4B(emp, riskCard, documentContext)); + groupDocs.push(await this.packBuffer(buf, `${base}/anex4b_${emp.idnp}`, `Anexa_4B_${emp.nume}_${emp.prenume}`)); + } + } else { + const radiationRows = this.buildRowVars(radiationEmployees, tipLabel); + const anex4BDoc = await this.renderAnexa('ANEXA_4B', radiationEmployees[0], ctx, radiationRows); + groupDocs.push(await this.pack(anex4BDoc, `${base}/anex4b_radiatii`, 'Anexa_4B_Radiatii')); + } + } + + // 4. Anexa 6 — Fișa de aptitudine per employee (blank, to be completed after checkup) + const perEmployee: Record<string, GeneratedDoc> = {}; + for (const emp of employees) { + const buf6 = this.docx.has('ANEXA_6') + ? this.docx.render('ANEXA_6', this.dataAnexa6(emp, tipLabel, { riskCard })) + : await Packer.toBuffer(await this.renderAnexa('ANEXA_6', emp, { riskCard, tipExamen: tipLabel, departmentName, rowIndex: 1 })); + perEmployee[emp.id] = await this.packBuffer(buf6, `${base}/anex6_${emp.idnp}`, `Anexa_6_${emp.nume}_${emp.prenume}`); + } + + return { groupDocs, perEmployee }; + } + + /** Generate Anexa 6 with completed verdict — called after medic_familie completes the checkup */ + async generateAnex6Completed( + employee: EmployeeForDoc, + riskCard: RiskCardData, + tipExamen: string, + verdict: string, + recomandari: string | undefined, + dataEfectuata: string | Date | undefined, + valabilPanaLa: string | Date | undefined, + semnatDe: string | undefined, + checkupId: string, + ): Promise<GeneratedDoc> { + const tipLabel = TIP_LABELS[tipExamen] ?? tipExamen; + const verdictLabels: Record<string, string> = { + apt: 'Apt', + apt_perioada_adaptare: 'Apt în perioada de adaptare', + apt_conditionat: 'Apt condiționat', + inapt_temporar: 'Inapt temporar', + inapt: 'Inapt', + }; + const buf = this.docx.has('ANEXA_6') + ? this.docx.render('ANEXA_6', this.dataAnexa6(employee, tipLabel, { + riskCard, + verdict, + recomandari, + dataCompletarii: dataEfectuata, + valabilPanaLa, + semnatDe, + })) + : await Packer.toBuffer(await this.renderAnexa('ANEXA_6', employee, { + riskCard, + tipExamen: tipLabel, + verdict: verdictLabels[verdict] ?? verdict, + recomandari, + rowIndex: 1, + })); + return this.packBuffer( + buf, + `medical/checkups/${checkupId}/anex6_final`, + `Anexa_6_Final_${employee.nume}_${employee.prenume}`, + ); + } +} diff --git a/apps/api/src/modules/medical/services/docx-template.service.ts b/apps/api/src/modules/medical/services/docx-template.service.ts new file mode 100644 index 0000000..7107ec2 --- /dev/null +++ b/apps/api/src/modules/medical/services/docx-template.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import PizZip from 'pizzip'; +import Docxtemplater from 'docxtemplater'; +import { AnexaType } from '@prisma/client'; + +const TEMPLATE_DIR = process.env.DOCX_TEMPLATE_DIR ?? join(process.cwd(), 'templates', 'docx'); + +const FILE_BY_TYPE: Record<AnexaType, string> = { + ANEXA_3: 'anexa-3.docx', + ANEXA_4: 'anexa-4.docx', + ANEXA_4A: 'anexa-4a.docx', + ANEXA_4B: 'anexa-4b.docx', + ANEXA_6: 'anexa-6.docx', +}; + +@Injectable() +export class DocxTemplateService { + private pathFor(type: AnexaType): string { + return join(TEMPLATE_DIR, FILE_BY_TYPE[type]); + } + + /** True if a user-authored .docx template exists for this Anexa. */ + has(type: AnexaType): boolean { + return existsSync(this.pathFor(type)); + } + + /** Render the .docx template with `data` (docxtemplater) and return the buffer. */ + render(type: AnexaType, data: Record<string, unknown>): Buffer { + const content = readFileSync(this.pathFor(type)); + const zip = new PizZip(content); + const doc = new Docxtemplater(zip, { + paragraphLoop: true, + linebreaks: true, + nullGetter: () => '', // câmpurile lipsă → gol (nu aruncă) + }); + doc.render(data); + return doc.getZip().generate({ type: 'nodebuffer', compression: 'DEFLATE' }) as Buffer; + } +} diff --git a/apps/api/src/modules/medical/services/medical-profile.service.ts b/apps/api/src/modules/medical/services/medical-profile.service.ts new file mode 100644 index 0000000..94b62ea --- /dev/null +++ b/apps/api/src/modules/medical/services/medical-profile.service.ts @@ -0,0 +1,93 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { UpsertMedicalProfileDto } from '../dto/medical-profile.dto'; + +@Injectable() +export class MedicalProfileService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + async findOne(employeeId: string) { + const profile = await this.prisma.employeeMedicalProfile.findUnique({ + where: { employeeId }, + include: { workplaceRiskCard: true, overexposures: { orderBy: { data: 'asc' } } }, + }); + if (!profile) return null; + + // dozaTotalaMsv is computed, not stored + const externa = Number(profile.dozaCumulataExternaMsv ?? 0); + const interna = Number(profile.dozaCumulataInternaMsv ?? 0); + + return { + ...profile, + dozaTotalaMsv: externa + interna, + }; + } + + async upsert(employeeId: string, dto: UpsertMedicalProfileDto, userId: string, role: string) { + if (dto.expusRadiatiiIonizante && !dto.dataIntrarii) { + throw new BadRequestException('Data intrării în mediu cu radiații este obligatorie pentru personalul expus'); + } + + await this.prisma.employee.findUniqueOrThrow({ where: { id: employeeId } }); + + const data = { + ocupatieCorm: dto.ocupatieCorm, + workplaceRiskCardId: dto.workplaceRiskCardId, + dataUltimControlMedical: dto.dataUltimControlMedical ? new Date(dto.dataUltimControlMedical) : undefined, + expusRadiatiiIonizante: dto.expusRadiatiiIonizante, + dataIntrarii: dto.dataIntrarii ? new Date(dto.dataIntrarii) : null, + expunereAnterioaraPerioda: dto.expunereAnterioaraPerioda, + expunereAnterioaraAni: dto.expunereAnterioaraAni, + dozaCumulataExternaMsv: dto.dozaCumulataExternaMsv, + dozaCumulataInternaMsv: dto.dozaCumulataInternaMsv, + }; + + // Supraexpuneri (Anexa 4B): set sent → replace whole set; cleared if not exposed. + const mapOverexposures = () => + (dto.expusRadiatiiIonizante ? dto.overexposures ?? [] : []).map((o) => ({ + fel: o.fel, + tipExpunere: o.tipExpunere, + data: o.data ? new Date(o.data) : null, + dozaMsv: o.dozaMsv, + })); + + const profile = await this.prisma.employeeMedicalProfile.upsert({ + where: { employeeId }, + create: { ...data, employeeId, overexposures: { create: mapOverexposures() } }, + update: { + ...data, + ...(dto.overexposures !== undefined || !dto.expusRadiatiiIonizante + ? { overexposures: { deleteMany: {}, create: mapOverexposures() } } + : {}), + }, + }); + + await this.audit.logChange({ + userId, userRole: role, + action: 'UPDATE', entity: 'EmployeeMedicalProfile', entityId: profile.id, + }); + return profile; + } + + // Returns employees whose last checkup is within 30 days of expiration (12-month cycle) + async findUpcomingExpirations() { + const today = new Date(); + const thirtyDaysFromNow = new Date(today); + thirtyDaysFromNow.setDate(today.getDate() + 30); + + // dataUltimControlMedical + 12 months < today + 30 days → expires soon + const oneYearAgoMinus30 = new Date(thirtyDaysFromNow); + oneYearAgoMinus30.setFullYear(oneYearAgoMinus30.getFullYear() - 1); + + return this.prisma.employeeMedicalProfile.findMany({ + where: { + dataUltimControlMedical: { lte: oneYearAgoMinus30 }, + }, + include: { + employee: { select: { id: true, idnp: true, nume: true, prenume: true, status: true } }, + workplaceRiskCard: { select: { name: true } }, + }, + }); + } +} diff --git a/apps/api/src/modules/medical/services/risk-cards.service.ts b/apps/api/src/modules/medical/services/risk-cards.service.ts new file mode 100644 index 0000000..ee2f3e7 --- /dev/null +++ b/apps/api/src/modules/medical/services/risk-cards.service.ts @@ -0,0 +1,96 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../../common/prisma/prisma.service'; +import { AuditService } from '../../../common/audit/audit.service'; +import { CreateRiskCardDto, UpdateRiskCardDto, RiskExposureDto } from '../dto/risk-card.dto'; + +// Maps a validated exposure DTO to the Prisma nested-create shape (drops cardId). +function mapExposure(e: RiskExposureDto): Prisma.WorkplaceRiskExposureCreateWithoutCardInput { + return { + tip: e.tip, + denumire: e.denumire, + cas: e.cas, + einecs: e.einecs, + clasificare: e.clasificare, + zonaAfectata: e.zonaAfectata, + timpExpunere: e.timpExpunere, + vep: e.vep, + vlep: e.vlep, + caracteristici: e.caracteristici, + procesVerbal: e.procesVerbal, + }; +} + +@Injectable() +export class RiskCardsService { + constructor(private readonly prisma: PrismaService, private readonly audit: AuditService) {} + + findAll() { + return this.prisma.workplaceRiskCard.findMany({ + orderBy: { name: 'asc' }, + include: { exposures: true, _count: { select: { profiles: true } } }, + }); + } + + async findOne(id: string) { + const card = await this.prisma.workplaceRiskCard.findUnique({ + where: { id }, + include: { + exposures: true, + profiles: { + include: { + employee: { select: { id: true, idnp: true, nume: true, prenume: true } }, + }, + }, + }, + }); + if (!card) throw new NotFoundException(); + return card; + } + + async create(dto: CreateRiskCardDto, userId: string, role: string) { + const { exposures, riskFactors, evaluareDetalii, anexeIgienicoSanitare, ...scalars } = dto; + const card = await this.prisma.workplaceRiskCard.create({ + data: { + ...scalars, + ...(riskFactors != null ? { riskFactors: riskFactors as Prisma.InputJsonValue } : {}), + ...(evaluareDetalii != null ? { evaluareDetalii: evaluareDetalii as Prisma.InputJsonValue } : {}), + ...(anexeIgienicoSanitare != null ? { anexeIgienicoSanitare: anexeIgienicoSanitare as Prisma.InputJsonValue } : {}), + ...(exposures?.length ? { exposures: { create: exposures.map(mapExposure) } } : {}), + }, + include: { exposures: true }, + }); + await this.audit.logChange({ userId, userRole: role, action: 'CREATE', entity: 'WorkplaceRiskCard', entityId: card.id }); + return card; + } + + async update(id: string, dto: UpdateRiskCardDto, userId: string, role: string) { + const { exposures, riskFactors, evaluareDetalii, anexeIgienicoSanitare, ...scalars } = dto; + + const updated = await this.prisma.$transaction(async (tx) => { + // exposures sent → replace the whole set for this card + if (exposures !== undefined) { + await tx.workplaceRiskExposure.deleteMany({ where: { cardId: id } }); + } + return tx.workplaceRiskCard.update({ + where: { id }, + data: { + ...scalars, + ...(riskFactors != null ? { riskFactors: riskFactors as Prisma.InputJsonValue } : {}), + ...(evaluareDetalii != null ? { evaluareDetalii: evaluareDetalii as Prisma.InputJsonValue } : {}), + ...(anexeIgienicoSanitare != null ? { anexeIgienicoSanitare: anexeIgienicoSanitare as Prisma.InputJsonValue } : {}), + ...(exposures !== undefined ? { exposures: { create: exposures.map(mapExposure) } } : {}), + }, + include: { exposures: true }, + }); + }); + + await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'WorkplaceRiskCard', entityId: id }); + return updated; + } + + async remove(id: string, userId: string, role: string) { + await this.prisma.workplaceRiskCard.delete({ where: { id } }); + await this.audit.logChange({ userId, userRole: role, action: 'DELETE', entity: 'WorkplaceRiskCard', entityId: id }); + } +} diff --git a/apps/api/src/modules/medical/services/storage.service.ts b/apps/api/src/modules/medical/services/storage.service.ts new file mode 100644 index 0000000..af7c9b4 --- /dev/null +++ b/apps/api/src/modules/medical/services/storage.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Client as MinioClient } from 'minio'; + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly logger = new Logger(StorageService.name); + private client!: MinioClient; + private bucket!: string; + + async onModuleInit() { + this.bucket = process.env.MINIO_BUCKET ?? 'hrm-docs'; + this.client = new MinioClient({ + endPoint: process.env.MINIO_ENDPOINT ?? 'localhost', + port: Number(process.env.MINIO_PORT ?? 9000), + useSSL: false, + accessKey: process.env.MINIO_ACCESS_KEY ?? 'minioadmin', + secretKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin', + }); + + try { + const exists = await this.client.bucketExists(this.bucket); + if (!exists) { + await this.client.makeBucket(this.bucket, 'us-east-1'); + this.logger.log(`Bucket ${this.bucket} created`); + } + } catch (e) { + this.logger.warn(`MinIO not reachable on init: ${(e as Error).message}`); + } + } + + async upload(key: string, buffer: Buffer, contentType: string): Promise<string> { + await this.client.putObject(this.bucket, key, buffer, buffer.length, { + 'Content-Type': contentType, + }); + return `s3://${this.bucket}/${key}`; + } + + presignedUrl(key: string, expirySec = 3600): Promise<string> { + return this.client.presignedGetObject(this.bucket, key, expirySec); + } + + async remove(key: string): Promise<void> { + await this.client.removeObject(this.bucket, key); + } +} diff --git a/apps/api/src/modules/medical/services/tiptap-to-docx.ts b/apps/api/src/modules/medical/services/tiptap-to-docx.ts new file mode 100644 index 0000000..28fa527 --- /dev/null +++ b/apps/api/src/modules/medical/services/tiptap-to-docx.ts @@ -0,0 +1,141 @@ +import { + Paragraph, TextRun, Table, TableRow, TableCell, + HeadingLevel, AlignmentType, WidthType, BorderStyle, + ITableBordersOptions, +} from 'docx'; + +type TiptapMark = { type: string }; +type TiptapNode = { + type: string; + attrs?: Record<string, unknown>; + content?: TiptapNode[]; + text?: string; + marks?: TiptapMark[]; +}; + +export type TemplateVars = Record<string, string>; + +interface ConvertOpts { + rows?: Record<string, string>[]; // tabel repetabil unic (Anexa 3) + rowSets?: Record<string, Record<string, string>[]>; // tabele repetabile numite (Anexa 4) +} + +const THIN_BORDER: ITableBordersOptions = { + top: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + left: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, + right: { style: BorderStyle.SINGLE, size: 1, color: '999999' }, +}; + +export function tiptapToDocx( + doc: TiptapNode, + vars: TemplateVars, + opts?: ConvertOpts, +): (Paragraph | Table)[] { + return (doc.content ?? []).flatMap((node) => convertBlock(node, vars, opts)); +} + +function convertBlock(node: TiptapNode, vars: TemplateVars, opts?: ConvertOpts): (Paragraph | Table)[] { + switch (node.type) { + case 'paragraph': + return [new Paragraph({ + alignment: resolveAlign(node.attrs?.textAlign as string | undefined), + children: inlineRuns(node.content ?? [], vars), + })]; + + case 'heading': + return [new Paragraph({ + heading: resolveHeading(node.attrs?.level as number | undefined), + alignment: resolveAlign(node.attrs?.textAlign as string | undefined), + children: inlineRuns(node.content ?? [], vars), + })]; + + case 'bulletList': + case 'orderedList': + return (node.content ?? []).flatMap((item) => + (item.content ?? []).flatMap((p) => convertBlock(p, vars, opts)), + ); + + case 'table': + return [buildTable(node, vars, opts)]; + + default: + return []; + } +} + +function inlineRuns(nodes: TiptapNode[], vars: TemplateVars): TextRun[] { + return nodes.flatMap((node) => { + if (node.type === 'text') { + const bold = node.marks?.some((m) => m.type === 'bold') ?? false; + const italics = node.marks?.some((m) => m.type === 'italic') ?? false; + const underline = node.marks?.some((m) => m.type === 'underline') ? {} : undefined; + return [new TextRun({ text: node.text ?? '', bold, italics, underline })]; + } + if (node.type === 'variableChip') { + const key = node.attrs?.key as string; + const value = vars[key] ?? `[${key}]`; + return [new TextRun({ text: value, bold: true })]; + } + if (node.type === 'hardBreak') { + return [new TextRun({ break: 1 })]; + } + return []; + }); +} + +function buildRow(rowNode: TiptapNode, vars: TemplateVars, opts?: ConvertOpts): TableRow { + return new TableRow({ + children: (rowNode.content ?? []).map((cellNode) => { + const paragraphs = (cellNode.content ?? []).flatMap((p) => convertBlock(p, vars, opts)); + return new TableCell({ + children: paragraphs.length > 0 + ? (paragraphs as Paragraph[]) + : [new Paragraph({ children: [] })], + borders: THIN_BORDER, + }); + }), + }); +} + +function buildTable(node: TiptapNode, vars: TemplateVars, opts?: ConvertOpts): Table { + const repeatRows = node.attrs?.repeatRows === true; + const rowsKey = node.attrs?.rowsKey as string | undefined; + // Named row-set (Anexa 4 factor tables) falls back to the single `rows` (Anexa 3). + const repeatData = rowsKey ? opts?.rowSets?.[rowsKey] : opts?.rows; + const children = node.content ?? []; + + let rows: TableRow[]; + if (repeatRows && children.length >= 2) { + const header = children[0]; + const template = children[1]; + rows = [buildRow(header, vars, opts)]; + for (const entry of repeatData ?? []) { + rows.push(buildRow(template, { ...vars, ...entry }, opts)); + } + } else { + rows = children.map((rowNode) => buildRow(rowNode, vars, opts)); + } + + return new Table({ + rows, + width: { size: 100, type: WidthType.PERCENTAGE }, + }); +} + +function resolveAlign(align?: string) { + if (align === 'center') return AlignmentType.CENTER; + if (align === 'right') return AlignmentType.RIGHT; + if (align === 'justify') return AlignmentType.JUSTIFIED; + return AlignmentType.LEFT; +} + +function resolveHeading(level?: number) { + const map = { + 1: HeadingLevel.HEADING_1, + 2: HeadingLevel.HEADING_2, + 3: HeadingLevel.HEADING_3, + 4: HeadingLevel.HEADING_4, + } as const; + return map[(level ?? 1) as 1 | 2 | 3 | 4] ?? HeadingLevel.HEADING_1; +} diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..d3081da --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { NotificationsService } from './notifications.service'; + +@Controller('notifications') +@UseGuards(AuthGuard('jwt'), RolesGuard) +@Roles('hr_admin') +export class NotificationsController { + constructor(private readonly notifications: NotificationsService) {} + + /** Preview the current expiry report without sending. */ + @Get('expiry-preview') + async preview(@Query('windowDays') windowDays?: string) { + return this.notifications.buildExpiryReport(windowDays ? Number(windowDays) : 30); + } + + /** Manually trigger the daily-expiry webhook (admin only). */ + @Post('trigger-daily-expiry') + async trigger(@Query('windowDays') windowDays?: string) { + const report = await this.notifications.buildExpiryReport(windowDays ? Number(windowDays) : 30); + const result = await this.notifications.sendReport(report); + return { ...result, items: report.items.length, totals: report.totals }; + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..a1b6e47 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -0,0 +1,49 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { BullModule, InjectQueue } from '@nestjs/bull'; +import { HttpModule } from '@nestjs/axios'; +import { Queue } from 'bull'; +import { Logger } from '@nestjs/common'; +import { DailyExpiryProcessor } from './workers/daily-expiry.processor'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +const DAILY_EXPIRY_QUEUE = 'daily-expiry'; +const DAILY_EXPIRY_JOB = 'daily-expiry-scan'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: DAILY_EXPIRY_QUEUE }), + HttpModule, + ], + controllers: [NotificationsController], + providers: [NotificationsService, DailyExpiryProcessor], + exports: [NotificationsService], +}) +export class NotificationsModule implements OnModuleInit { + private readonly logger = new Logger(NotificationsModule.name); + + constructor(@InjectQueue(DAILY_EXPIRY_QUEUE) private readonly queue: Queue) {} + + async onModuleInit(): Promise<void> { + // Remove stale repeatables first so changes to the cron expression take effect + const repeatables = await this.queue.getRepeatableJobs(); + for (const r of repeatables) { + if (r.name === DAILY_EXPIRY_JOB) { + await this.queue.removeRepeatableByKey(r.key); + } + } + // Schedule daily at 08:00 Europe/Bucharest + await this.queue.add( + DAILY_EXPIRY_JOB, + {}, + { + repeat: { cron: '0 8 * * *', tz: 'Europe/Bucharest' }, + removeOnComplete: 20, + removeOnFail: 50, + }, + ); + this.logger.log('Daily expiry job scheduled (cron 0 8 * * * Europe/Bucharest)'); + } +} + +export { DAILY_EXPIRY_QUEUE, DAILY_EXPIRY_JOB }; diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..6c4510c --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -0,0 +1,135 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { PrismaService } from '../../common/prisma/prisma.service'; + +export interface ExpiryItem { + category: 'document' | 'qualification' | 'contract' | 'medical' | 'sanction'; + employeeId: string; + employeeName: string; + idnp: string; + label: string; + expiresAt: string; // ISO YYYY-MM-DD + daysUntil: number; +} + +export interface ExpiryReport { + generatedAt: string; + totals: Record<ExpiryItem['category'], number>; + items: ExpiryItem[]; +} + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly http: HttpService, + ) {} + + /** Build the expiry report for everything that expires within `windowDays` (or has expired). */ + async buildExpiryReport(windowDays = 30): Promise<ExpiryReport> { + const now = new Date(); + const horizon = new Date(now.getTime() + windowDays * 24 * 3600 * 1000); + const items: ExpiryItem[] = []; + + // 1. Identity documents + const idDocs = await this.prisma.identityDocument.findMany({ + where: { dataExpirarii: { lte: horizon } }, + include: { employee: { select: { id: true, nume: true, prenume: true, idnp: true } } }, + }); + for (const d of idDocs) { + items.push(this.toItem('document', d.employee, `${d.tipAct} ${d.seria ?? ''}${d.nr}`.trim(), d.dataExpirarii, now)); + } + + // 2. Qualifications + const quals = await this.prisma.qualification.findMany({ + where: { dataExpirarii: { lte: horizon, not: null } }, + include: { employee: { select: { id: true, nume: true, prenume: true, idnp: true } } }, + }); + for (const q of quals) { + if (!q.dataExpirarii) continue; + const lbl = q.specialitate ? `${q.categorie} — ${q.specialitate}` : String(q.categorie); + items.push(this.toItem('qualification', q.employee, lbl, q.dataExpirarii, now)); + } + + // 3. Contracts (fixed-term) — dataTerminarii within window + const contracts = await this.prisma.employmentContract.findMany({ + where: { dataTerminarii: { lte: horizon, not: null } }, + include: { employee: { select: { id: true, nume: true, prenume: true, idnp: true } } }, + }); + for (const c of contracts) { + if (!c.dataTerminarii) continue; + items.push(this.toItem('contract', c.employee, `Contract ${c.nrCim}`, c.dataTerminarii, now)); + } + + // 4. Medical — next planned checkup (dataPlanificata, not yet executed) + const medical = await this.prisma.medicalCheckup.findMany({ + where: { dataPlanificata: { lte: horizon }, dataEfectuata: null }, + include: { employee: { select: { id: true, nume: true, prenume: true, idnp: true } } }, + }); + for (const m of medical) { + items.push(this.toItem('medical', m.employee, `Control medical ${m.tip}`, m.dataPlanificata, now)); + } + + // 5. Disciplinary sanctions — expiry transition (still active) + const sanctions = await this.prisma.disciplinarySanction.findMany({ + where: { dataExpirarii: { lte: horizon }, isStinsa: false }, + include: { employee: { select: { id: true, nume: true, prenume: true, idnp: true } } }, + }); + for (const s of sanctions) { + items.push(this.toItem('sanction', s.employee, `Sancțiune ${s.tip}`, s.dataExpirarii, now)); + } + + items.sort((a, b) => a.daysUntil - b.daysUntil); + + const totals: ExpiryReport['totals'] = { + document: 0, qualification: 0, contract: 0, medical: 0, sanction: 0, + }; + for (const it of items) totals[it.category] += 1; + + return { generatedAt: now.toISOString(), totals, items }; + } + + async sendReport(report: ExpiryReport): Promise<{ delivered: boolean; reason?: string }> { + const webhook = process.env.N8N_WEBHOOK_BASE; + if (!webhook) { + this.logger.warn('N8N_WEBHOOK_BASE not set — skipping expiry notification webhook'); + return { delivered: false, reason: 'N8N_WEBHOOK_BASE not configured' }; + } + try { + await firstValueFrom(this.http.post(`${webhook}/daily-expiry`, report)); + this.logger.log( + `Daily expiry report sent (${report.items.length} items: ` + + `${report.totals.document} doc, ${report.totals.qualification} calif, ` + + `${report.totals.contract} contr, ${report.totals.medical} med, ` + + `${report.totals.sanction} sanct)`, + ); + return { delivered: true }; + } catch (err) { + const msg = (err as Error).message; + this.logger.error(`Failed to POST daily-expiry webhook: ${msg}`); + return { delivered: false, reason: msg }; + } + } + + private toItem( + category: ExpiryItem['category'], + employee: { id: string; nume: string; prenume: string; idnp: string }, + label: string, + expiresAt: Date, + now: Date, + ): ExpiryItem { + const daysUntil = Math.floor((expiresAt.getTime() - now.getTime()) / (24 * 3600 * 1000)); + return { + category, + employeeId: employee.id, + employeeName: `${employee.nume} ${employee.prenume}`, + idnp: employee.idnp, + label, + expiresAt: expiresAt.toISOString().slice(0, 10), + daysUntil, + }; + } +} diff --git a/apps/api/src/modules/notifications/workers/daily-expiry.processor.ts b/apps/api/src/modules/notifications/workers/daily-expiry.processor.ts new file mode 100644 index 0000000..e65588a --- /dev/null +++ b/apps/api/src/modules/notifications/workers/daily-expiry.processor.ts @@ -0,0 +1,24 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { Injectable, Logger } from '@nestjs/common'; +import { NotificationsService } from '../notifications.service'; +import { DAILY_EXPIRY_JOB } from '../notifications.module'; + +@Injectable() +@Processor('daily-expiry') +export class DailyExpiryProcessor { + private readonly logger = new Logger(DailyExpiryProcessor.name); + + constructor(private readonly notifications: NotificationsService) {} + + @Process(DAILY_EXPIRY_JOB) + async handle(_job: Job): Promise<void> { + this.logger.log('Running daily expiry scan…'); + const report = await this.notifications.buildExpiryReport(30); + if (report.items.length === 0) { + this.logger.log('No expiring items in the next 30 days — skipping webhook'); + return; + } + await this.notifications.sendReport(report); + } +} diff --git a/apps/api/src/modules/reference/reference.controller.ts b/apps/api/src/modules/reference/reference.controller.ts new file mode 100644 index 0000000..cba7672 --- /dev/null +++ b/apps/api/src/modules/reference/reference.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PrismaService } from '../../common/prisma/prisma.service'; + +@Controller('reference') +@UseGuards(AuthGuard('jwt'), RolesGuard) +@Roles('hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'medic_familie', 'quality_auditor', 'employee') +export class ReferenceController { + constructor(private readonly prisma: PrismaService) {} + + @Get('disability-grades') + disabilityGrades() { + return this.prisma.disabilityGrade.findMany({ orderBy: { name: 'asc' } }); + } + + @Get('tax-exemptions') + taxExemptions() { + return this.prisma.taxExemption.findMany({ orderBy: { code: 'asc' } }); + } + + @Get('work-schedules') + workSchedules() { + return this.prisma.workSchedule.findMany({ orderBy: { name: 'asc' } }); + } + + @Get('departments/flat') + departmentsFlat() { + return this.prisma.department.findMany({ orderBy: { name: 'asc' } }); + } +} diff --git a/apps/api/src/modules/reference/reference.module.ts b/apps/api/src/modules/reference/reference.module.ts new file mode 100644 index 0000000..f734306 --- /dev/null +++ b/apps/api/src/modules/reference/reference.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { ReferenceController } from './reference.controller'; + +@Module({ + controllers: [ReferenceController], +}) +export class ReferenceModule {} diff --git a/apps/api/templates/docx/README.md b/apps/api/templates/docx/README.md new file mode 100644 index 0000000..2717141 --- /dev/null +++ b/apps/api/templates/docx/README.md @@ -0,0 +1,162 @@ +# Șabloane DOCX — Control medical (docxtemplater) + +Сюда кладутся **`.docx`-шаблоны** (вы их делаете в Word, вставляя плейсхолдеры). Движок генерации +загружает нужный файл, подставляет данные и отдаёт готовый документ. + +- **Где лежат:** `apps/api/templates/docx/` +- **Движок:** `docxtemplater` + `pizzip` (уже в зависимостях). +- **Синтаксис плейсхолдеров:** фигурные скобки `{...}`. + +## Имена файлов (фиксированные — по ним движок находит шаблон) + +| Файл | Источник (регламент) | На что генерируется | +|------|----------------------|---------------------| +| `anexa-3.docx` | Anexa 3 — Fișa de solicitare | 1 на группу (карту риска) | +| `anexa-4.docx` | Anexa 4 — Fișa de evaluare a riscurilor | 1 на карту риска (`tipFisa = STANDARD`) | +| `anexa-4a.docx` | Anexa 4A — muncă la distanță/digital | 1 на карту риска (`tipFisa = DISTANTA_DIGITAL`) | +| `anexa-4b.docx` | Anexa 4B — supliment radiații | 1 на каждого облучаемого сотрудника | +| `anexa-6.docx` | Anexa 6 — Fișa de aptitudine | 1 на каждого сотрудника (вердикт врача) | + +## Синтаксис docxtemplater (шпаргалка для Word) + +| Нужно | Пишете в шаблоне | +|------|------------------| +| Простое значение | `{numePrenume}` | +| Таблица/список (повтор строки) | в строке таблицы: `{#chimici}` … `{denumire}` `{vlep}` … `{/chimici}` | +| Чек-бокс | `{cbEchipa}` — движок подставит `☑` или `☐` | +| Условный блок | `{#esteRadiatii}…текст…{/esteRadiatii}` (показать, если true); `{^esteRadiatii}…{/esteRadiatii}` (если false) | + +> ⚠️ Циклы в таблицах: `{#name}` ставится в **первую ячейку строки-шаблона**, `{/name}` — в **последнюю ячейку той же строки**. Эта строка повторится для каждого элемента массива. + +> ⚠️ Не оставляйте «висячих» `{` без пары — docxtemplater упадёт. Один плейсхолдер не должен быть разорван форматированием Word (выделите и наберите его одним стилем). + +--- + +# Контракт данных по приложениям + +Ниже — **точная форма `data`**, которую движок передаёт в каждый шаблон, и откуда берётся каждое значение. +Вставляйте в `.docx` плейсхолдеры именно с этими именами. + +## Общее (есть во всех) +| Плейсхолдер | Значение | Источник | +|---|---|---| +| `{unitatea}` | Medpark International Hospital | константа | +| `{idno}` | 1003600035476 | константа | +| `{adresa}` | str. Nicolae Testemițanu 29, Chișinău | константа | +| `{dataCompletarii}` | дата генерации (DD.MM.YYYY) | now() | + +## Контекст генерации batch +Эти поля заполняются в modal-е «Inițiere control medical» и используются в Anexa 3 / 4 / 4A / 4B: +`{telefon}` `{fax}` `{email}` `{solicitant}` `{functia}`. + +`{telefonFiliala}` берётся из `WorkplaceRiskCard.telefonFiliala`. + +--- + +## `anexa-3.docx` — Fișa de solicitare +**Уровень:** группа сотрудников (по карте риска). + +Шапка: `{unitatea}` `{idno}` `{adresa}` `{telefon}` `{fax}` `{email}` `{filiala}` `{adresaFiliala}` `{telefonFiliala}` +Подвал: `{dataCompletarii}` `{solicitant}` `{functia}` + +**Таблица сотрудников** — строку оборачиваете `{#angajati}` … `{/angajati}`: +| Плейсхолдер (в строке) | Значение | Источник | +|---|---|---| +| `{nr}` | порядковый № | счётчик | +| `{numePrenume}` | Фамилия Имя | Employee.nume/prenume | +| `{anNastere}` | год рождения | Employee.dataNasterii | +| `{idnp}` | IDNP | Employee.idnp | +| `{tipExamen}` | тип контроля (текст) | Batch.tip | +| `{ocupatieCorm}` | occupația CORM | EmployeeMedicalProfile.ocupatieCorm | +| `{caem}` | CAEM (diviziune) | WorkplaceRiskCard.caemDiviziune | +| `{numarLoc}` | № locului de muncă | WorkplaceRiskCard.numarulLoculuiDeMunca | +| `{factorRisc}` | факторы (через запятую) | WorkplaceRiskCard.exposures[].denumire | + +--- + +## `anexa-4.docx` — Fișa de evaluare a riscurilor profesionale +**Уровень:** карта риска (`WorkplaceRiskCard`). + +**Шапка:** `{unitatea}` `{adresa}` `{telefon}` `{fax}` `{email}` `{filiala}` `{adresaFiliala}` `{telefonFiliala}` `{caem2}` `{cormSubgrupa}` `{directiaSectia}` `{numarLoc}` `{caemDiviziune}` `{numarLucratori}` `{clasa}` + +**Чек-боксы описательного блока** (`☑`/`☐`) — источник `WorkplaceRiskCard.evaluareDetalii`: +`{cbEchipa}` `{cbSchimbNoapte}` `{cbPauze}` +`{cbInfectare}` `{cbElectrocutare}` `{cbTensiuneInalta}` `{cbInecare}` `{cbAsfixiere}` `{cbStrivire}` `{cbTaiere}` `{cbIntepare}` `{cbLovire}` `{cbMuscatura}` `{cbMicrotraumatisme}` +`{cbConduceMasina}` `{categorieConducere}` `{cbUtilajeIntrauzinal}` +`{spatiuL}` `{spatiul}` `{spatiuH}` `{cbSuprafVerticala}` `{cbSuprafOrizontala}` `{cbSuprafOblica}` `{cbMuncaIzolare}` `{cbMuncaInaltime}` `{cbMuncaMiscare}` +`{cbPozitieOrtostatica}` `{cbPozitieAsezat}` `{cbPozitieAplecata}` `{cbPozitieMixta}` `{cbPozitieFortata}` +`{cbColoanaCervicala}` `{cbColoanaToracala}` `{cbColoanaLombara}` +`{cbManipRidicare}` `{cbManipCoborare}` `{cbManipImpingere}` `{cbManipTragere}` `{cbManipPurtare}` `{cbManipDeplasare}` `{greutateMaxima}` +`{cbVizuale}` `{cbAuditive}` `{cbNeuropsihice}` +Microclimat/iluminat: `{cbMicroclimatInterior}` `{cbMicroclimatExterior}` `{cbCaloriceRece}` `{cbCaloriceCalda}` `{cbIluminatSuficient}` `{cbIluminatInsuficient}` `{cbIluminatNatural}` `{cbIluminatArtificial}` `{cbIluminatMixt}` + +**Факторные таблицы** — каждая = свой цикл; источник `WorkplaceRiskCard.exposures` (сгруппированы по `tip`): +Секции также имеют чек-боксы наличия данных: `{cbChimici}`/`{cbChimiciNu}`, `{cbPulberi}`/`{cbPulberiNu}`, `{cbBiologici}`/`{cbBiologiciNu}`, `{cbZgomot}`/`{cbZgomotNu}`, `{cbVibratii}`/`{cbVibratiiNu}`, `{cbCampEM}`/`{cbCampEMNu}`, `{cbOptice}`/`{cbOpticeNu}`. +| Цикл | Поля строки | +|---|---| +| `{#chimici}…{/chimici}` | `{denumire}` `{cas}` `{einecs}` `{timp}` `{vep}` `{vlep}` `{caracteristici}` | +| `{#pulberi}…{/pulberi}` | те же | +| `{#biologici}…{/biologici}` | `{denumire}` `{clasificare}` `{note}` | +| `{#zgomot}…{/zgomot}` | `{denumire}` `{timp}` `{vep}` `{vlep}` `{caracteristici}` | +| `{#vibratii}…{/vibratii}` | `{denumire}` `{zona}` `{timp}` `{vep}` `{vlep}` `{caracteristici}` | +| `{#campEM}…{/campEM}` | `{denumire}` `{zona}` `{timp}` `{vep}` `{vlep}` `{caracteristici}` | +| `{#optice}…{/optice}` | `{denumire}` `{zona}` `{timp}` `{vep}` `{vlep}` `{caracteristici}` | + +**Радиация ионизирующая** (источник `WorkplaceRiskCard.radiatii*`): +`{cbRadiatii}` `{cbRadiatiiNu}` `{radGrupa}` `{radSurse}` `{radTipExpunere}` `{radAparatura}` `{radMasuri}` + +**Подвал:** `{protectieColectiva}` `{protectieIndividuala}` `{echipament}` `{cbVestiar}` `{cbChiuveta}` `{cbWc}` `{cbDus}` `{cbSalaMese}` `{cbRecreere}` `{observatii}` + +--- + +## `anexa-4a.docx` — muncă la distanță / platforme digitale +**Уровень:** карта риска (`tipFisa = DISTANTA_DIGITAL`). **Без факторных таблиц.** + +Шапка: `{unitatea}` `{adresa}` `{telefon}` `{fax}` `{email}` `{filiala}` `{adresaFiliala}` `{telefonFiliala}` `{caem2}` `{cormSubgrupa}` `{directiaSectia}` `{numarLoc}` `{caemDiviziune}` `{clasa}` +Тело: `{cbEchipa}` `{oreZi}` `{schimburi}` `{cbSchimbNoapte}` `{cbPauze}` **`{cbLucruMonitor}`** **`{cbPlatformeDigitale}`** +`{cbConduceMasina}` `{categorieConducere}` `{operatiuni}` +`{cbDeplasari}` `{deplasariDescriere}` +`{cbManipRidicare}…{cbManipDeplasare}` `{greutateMaxima}` +`{cbVizuale}` `{cbAuditive}` `{cbNeuropsihice}` `{alteRiscuri}` +Подвал: `{dataCompletarii}` + +--- + +## `anexa-4b.docx` — Supliment radiații ionizante +**Уровень:** один облучаемый сотрудник. Источник: `EmployeeMedicalProfile` + `overexposures`. + +Шапка: `{unitatea}` `{adresa}` `{telefon}` `{fax}` `{email}` `{filiala}` `{adresaFiliala}` `{telefonFiliala}` `{caem2}` +Контекст места: `{cormSubgrupa}` `{directiaSectia}` `{numarLoc}` `{caemDiviziune}` +Сотрудник: `{numePrenume}` `{idnp}` + +| Плейсхолдер | Значение | Источник | +|---|---|---| +| `{cbRadiatii}` | ☑/☐ | profile.expusRadiatiiIonizante | +| `{dataIntrarii}` | дата | profile.dataIntrarii | +| `{expAnterioaraPerioada}` | период | profile.expunereAnterioaraPerioda | +| `{expAnterioaraAni}` | лет | profile.expunereAnterioaraAni | +| `{dozaExterna}` | mSv | profile.dozaCumulataExternaMsv | +| `{dozaInterna}` | mSv | profile.dozaCumulataInternaMsv | +| `{dozaTotala}` | mSv (ext+int) | вычисляется | + +Циклы supraexpuneri (источник `overexposures`, разделены по `fel`): +- `{#supraexpExceptionale}` `{tipExpunere}` `{data}` `{doza}` `{/supraexpExceptionale}` +- `{#supraexpAccidentale}` `{tipExpunere}` `{data}` `{doza}` `{/supraexpAccidentale}` + +Подвал: `{dataCompletarii}` + +--- + +## `anexa-6.docx` — Fișa de aptitudine +**Уровень:** один сотрудник. Источник: `MedicalCheckup` (вердикт врача). + +Шапка/сотрудник: `{unitatea}` `{adresa}` `{numePrenume}` `{idnp}` `{anNastere}` `{ocupatieCorm}` `{departament}` `{caemDiviziune}` `{numarLoc}` `{factorRisc}` `{tipExamen}` `{dataCompletarii}` +Вердикт (чек-боксы): `{cbApt}` `{cbAptAdaptare}` `{cbAptConditionat}` `{cbInaptTemporar}` `{cbInapt}` +`{recomandari}` `{valabilPanaLa}` `{semnatDe}`. +`{valabilPanaLa}` и `{semnatDe}` заполняет medic_familie при finalizarea controlului. + +--- + +## Статус движка +`DocxTemplateService.render(type, data)` уже подключён. Если файл `apps/api/templates/docx/anexa-*.docx` +существует, bulk-generation использует официальный `.docx`-шаблон через `docxtemplater`; старый `docx`+tiptap-JSON renderer остаётся fallback-ом. diff --git a/apps/api/templates/docx/anexa-3.docx b/apps/api/templates/docx/anexa-3.docx new file mode 100644 index 0000000..f513d8e Binary files /dev/null and b/apps/api/templates/docx/anexa-3.docx differ diff --git a/apps/api/templates/docx/anexa-4.docx b/apps/api/templates/docx/anexa-4.docx new file mode 100644 index 0000000..a1f969e Binary files /dev/null and b/apps/api/templates/docx/anexa-4.docx differ diff --git a/apps/api/templates/docx/anexa-4a.docx b/apps/api/templates/docx/anexa-4a.docx new file mode 100644 index 0000000..2d38d95 Binary files /dev/null and b/apps/api/templates/docx/anexa-4a.docx differ diff --git a/apps/api/templates/docx/anexa-4b.docx b/apps/api/templates/docx/anexa-4b.docx new file mode 100644 index 0000000..ec3143e Binary files /dev/null and b/apps/api/templates/docx/anexa-4b.docx differ diff --git a/apps/api/templates/docx/anexa-6.docx b/apps/api/templates/docx/anexa-6.docx new file mode 100644 index 0000000..6cd20bb Binary files /dev/null and b/apps/api/templates/docx/anexa-6.docx differ diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..a1c778d --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..684e234 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +# Build context = monorepo root (hrm-medpark/) + +FROM node:20-bookworm-slim AS build +ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" +RUN corepack enable +WORKDIR /repo +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +RUN pnpm install --frozen-lockfile +COPY apps/web apps/web +RUN pnpm --filter web build + +# ---- nginx serves the static build and proxies /api to the api container ---- +FROM nginx:alpine AS runtime +COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/web/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..392e122 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="ro"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>HRM Medpark + + +
+ + + diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..3fb63e9 --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # API requests are proxied to the NestJS container (same origin → no CORS, no build-time URL) + location /api/ { + proxy_pass http://api:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3966af0 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "web", + "version": "0.1.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@hookform/resolvers": "^3.3.4", + "@mantine/core": "^7.6.2", + "@mantine/dates": "^7.6.2", + "@mantine/form": "^7.6.2", + "@mantine/hooks": "^7.6.2", + "@mantine/modals": "^7.6.2", + "@mantine/notifications": "^7.6.2", + "@tabler/icons-react": "^3.41.1", + "@tanstack/react-query": "^5.28.6", + "@tiptap/core": "^3.23.1", + "@tiptap/extension-table": "^3.23.1", + "@tiptap/extension-underline": "^3.23.1", + "@tiptap/pm": "^3.23.1", + "@tiptap/react": "^3.23.1", + "@tiptap/starter-kit": "^3.23.1", + "axios": "^1.6.8", + "dayjs": "^1.11.10", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^8.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.51.0", + "react-i18next": "^14.1.0", + "react-router-dom": "^6.22.3", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/react": "^18.2.74", + "@types/react-dom": "^18.2.24", + "@vitejs/plugin-react": "^4.2.1", + "postcss": "^8.4.38", + "postcss-preset-mantine": "^1.13.0", + "typescript": "^5.4.2", + "vite": "^5.2.6" + } +} diff --git a/apps/web/public/logo-medpark.png b/apps/web/public/logo-medpark.png new file mode 100644 index 0000000..02611a2 Binary files /dev/null and b/apps/web/public/logo-medpark.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..6a4716d --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AppShell, Group, Text, Stack, UnstyledButton, Menu, Avatar, Badge } from '@mantine/core'; +import { + IconLayoutDashboard, IconUsers, IconSitemap, + IconTargetArrow, IconShieldExclamation, IconStethoscope, IconMailbox, + IconFileDescription, IconBox, +} from '@tabler/icons-react'; +import { EmployeesPage } from './pages/employees/EmployeesPage'; +import { EmployeeDetailPage } from './pages/employees/EmployeeDetailPage'; +import { DepartmentsPage } from './pages/departments/DepartmentsPage'; +import { EvaluationPage } from './pages/evaluation/EvaluationPage'; +import { CampaignDetailPage } from './pages/evaluation/CampaignDetailPage'; +import { EvaluationFormPage } from './pages/evaluation/EvaluationFormPage'; +import { RiskCardsPage } from './pages/medical/RiskCardsPage'; +import { MedicalControlPage } from './pages/medical/MedicalControlPage'; +import { MedicalInboxPage } from './pages/medical/MedicalInboxPage'; +import { DashboardPage } from './pages/dashboard/DashboardPage'; +import { LoginPage } from './pages/auth/LoginPage'; +import { ContractsPage } from './pages/contracts/ContractsPage'; +import { InventoryPage } from './pages/inventory/InventoryPage'; + +const font = "'Montserrat', Arial, sans-serif"; +const teal = '#008286'; + +// ── Navigation items ────────────────────────────────────── + +interface NavItem { labelKey: string; path: string; icon: React.ReactNode; roles?: string[] } + +// roles: undefined = all authenticated users; defined = only those roles +const NAV_ITEMS: NavItem[] = [ + { labelKey: 'nav.dashboard', path: '/dashboard', icon: }, + { labelKey: 'nav.employees', path: '/employees', icon: , roles: ['hr_admin', 'hr_specialist', 'manager', 'nursing_director'] }, + { labelKey: 'nav.departments', path: '/departments', icon: , roles: ['hr_admin', 'hr_specialist'] }, + { labelKey: 'nav.contracts', path: '/contracts', icon: , roles: ['hr_admin', 'hr_specialist'] }, + { labelKey: 'nav.inventory', path: '/inventory', icon: , roles: ['hr_admin', 'hr_specialist'] }, + { labelKey: 'nav.evaluation', path: '/evaluation', icon: , roles: ['hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor'] }, + { labelKey: 'nav.risk_cards', path: '/risk-cards', icon: , roles: ['hr_admin', 'hr_specialist', 'manager', 'medic_familie'] }, + { labelKey: 'nav.medical', path: '/medical', icon: , roles: ['hr_admin', 'hr_specialist', 'manager'] }, + { labelKey: 'nav.inbox', path: '/medic-inbox', icon: , roles: ['hr_admin', 'medic_familie'] }, +]; + +function NavLink({ item }: { item: NavItem }) { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const active = location.pathname === item.path || + (item.path !== '/dashboard' && location.pathname.startsWith(item.path + '/')); + + return ( + navigate(item.path)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 18px 12px 15px', + borderRadius: 6, + borderLeft: active ? `3px solid ${teal}` : '3px solid transparent', + background: active ? '#e6f4f4' : 'transparent', + color: active ? teal : '#58595b', + fontFamily: font, + fontWeight: active ? 600 : 400, + fontSize: '0.9375rem', + width: '100%', + cursor: 'pointer', + }} + onMouseEnter={(e) => { + if (!active) (e.currentTarget as HTMLElement).style.background = '#f8f9fa'; + }} + onMouseLeave={(e) => { + if (!active) (e.currentTarget as HTMLElement).style.background = 'transparent'; + }} + > + {item.icon} + {t(item.labelKey)} + + ); +} + +// ── Shell ───────────────────────────────────────────────── + +function ProtectedRoute({ roles, children }: { roles?: string[]; children: React.ReactNode }) { + const role = localStorage.getItem('kc_role') ?? ''; + if (roles && !roles.includes(role)) return ; + return <>{children}; +} + +function RouteFade({ children }: { children: React.ReactNode }) { + const location = useLocation(); + return ( +
+ {children} +
+ ); +} + +function Shell({ onLogout }: { onLogout: () => void }) { + const username = localStorage.getItem('kc_username') ?? 'HR Admin'; + const role = localStorage.getItem('kc_role') ?? 'hr_admin'; + + const visibleNav = NAV_ITEMS.filter(item => !item.roles || item.roles.includes(role)); + + const initials = username + .split(' ') + .map((w) => w[0]?.toUpperCase() ?? '') + .slice(0, 2) + .join(''); + + return ( + + {/* ── HEADER ── */} + + + Medpark International Hospital { + const el = e.target as HTMLImageElement; + el.style.display = 'none'; + }} + /> + + + + {role} + + + + + + {initials || 'HR'} + + + {username} + + + + + {role} + + + Ieșire + + + + + + + + {/* ── NAVBAR ── */} + + + + HRM + + + {visibleNav.map((item) => ( + + ))} + + + + {/* ── CONTENT ── */} + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} + +// ── Root ────────────────────────────────────────────────── + +export default function App() { + const [authed, setAuthed] = useState(() => !!localStorage.getItem('kc_token')); + + const handleLogin = () => setAuthed(true); + + const handleLogout = () => { + localStorage.removeItem('kc_token'); + localStorage.removeItem('kc_username'); + localStorage.removeItem('kc_role'); + setAuthed(false); + }; + + if (!authed) { + return ; + } + + return ( + + + + ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..9547c04 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +export const apiClient = axios.create({ + baseURL: '/api/v1', +}); + +// Прикрепляем Keycloak-токен из localStorage (Keycloak.js управляет им на клиенте) +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem('kc_token'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +apiClient.interceptors.response.use( + (r) => r, + (err) => Promise.reject(err), +); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts new file mode 100644 index 0000000..9d83b09 --- /dev/null +++ b/apps/web/src/api/types.ts @@ -0,0 +1,544 @@ +// Shared TypeScript interfaces mirroring Prisma models as returned by the API. +// All dates are ISO strings from JSON. + +export type Sex = 'F' | 'M'; +export type MaritalStatus = 'casatorit' | 'necasatorit' | 'divortat' | 'vaduv'; +export type EmployeeStatus = 'activ' | 'concediat' | 'suspendat'; +export type DocumentType = 'buletin_de_identitate' | 'pasaport'; +export type FamilyMemberType = 'contact_principal' | 'sot' | 'sotie' | 'mama' | 'tata' | 'copil'; +export type StudyType = 'superioare' | 'medii_de_specialitate' | 'secundare_tehnice' | 'medii'; +export type StudyLevel = 'de_baza' | 'postuniversitar'; +export type PostUniversityType = 'masterat' | 'rezidentiat' | 'secundariat' | 'altele'; +export type DiplomaStatus = 'confirmata' | 'neconfirmata'; +export type QualificationCategory = 'fara' | 'cat_II' | 'cat_I' | 'superioara'; +export type ScientificTitle = 'doctor' | 'doctor_habilitat'; +export type TrainingType = 'orientare' | 'intern' | 'extern_RM' | 'extern_international'; +export type DisciplinarySanctionType = 'avertisment' | 'mustrare' | 'mustrare_aspra'; +export type ContractPeriod = 'determinata' | 'nedeterminata' | 'replasare_temporara'; +export type ContractCategory = 'principal' | 'secundar'; +export type ContractType = 'de_baza' | 'cumul'; +export type SalaryType = 'fix' | 'pe_ore' | 'in_acord'; + +// ─── Reference entities ────────────────────────────────────── + +export interface DisabilityGrade { + id: string; + code: string; + name: string; +} + +export interface TaxExemption { + id: string; + code: string; + description: string; +} + +export interface WorkSchedule { + id: string; + name: string; + daysWork: number; + daysRest: number; + hoursPerDay: number; +} + +export interface Department { + id: string; + name: string; + code: string | null; + parentId: string | null; + children?: Department[]; + parent?: Department | null; +} + +// ─── Sub-entities ──────────────────────────────────────────── + +export interface IdentityDocument { + id: string; + employeeId: string; + tipAct: DocumentType; + seria: string | null; + nr: string; + dataEmiterii: string; + autoritateEmitenta: string; + dataExpirarii: string; + createdAt: string; + updatedAt: string; +} + +export interface FamilyMember { + id: string; + employeeId: string; + tip: FamilyMemberType; + numePrenume: string; + dataNasterii: string | null; + idnp: string | null; + telefon: string | null; + tipScutireId: string | null; + tipScutire: TaxExemption | null; + createdAt: string; + updatedAt: string; +} + +export interface Education { + id: string; + employeeId: string; + tipStudii: StudyType; + institutia: string; + specialitatea: string; + dataAbsolvirii: string | null; + nrSeriaDiploma: string | null; + dataEmiterii: string | null; + nrInregistrare: string | null; + confirmare: DiplomaStatus | null; + nivel: StudyLevel | null; + tipPostuniversitar: PostUniversityType | null; + createdAt: string; + updatedAt: string; +} + +export interface Qualification { + id: string; + employeeId: string; + categorie: QualificationCategory; + dataObtinerii: string | null; + dataUltimeiConfirmari: string | null; + dataExpirarii: string | null; + specialitate: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Training { + id: string; + employeeId: string; + denumire: string; + inceput: string; + sfirsit: string | null; + tip: TrainingType; + tara: string | null; + nrOre: number | null; + organizatia: string | null; + certificat: boolean; + cost: string | null; + createdAt: string; + updatedAt: string; +} + +export interface DisciplinarySanction { + id: string; + employeeId: string; + tip: DisciplinarySanctionType; + dataAplicarii: string; + dataExpirarii: string; + isStinsa: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Benefit { + id: string; + employeeId: string; + uniformaId: string | null; + uniforma: InventoryItem | null; + halatId: string | null; + halat: InventoryItem | null; + ciupiciId: string | null; + ciupici: InventoryItem | null; + vestaId: string | null; + vesta: InventoryItem | null; + ticheteMasa: boolean; + valoareTichet: string | null; + alimentatiePersonal: boolean; + abonamentTel: string | null; + aparatTelefonId: string | null; + aparatTelefon: InventoryItem | null; + cardCompanie: string | null; + automobilServiciu: string | null; + createdAt: string; + updatedAt: string; +} + +export type InventoryItemType = 'uniforma' | 'halat' | 'ciupici' | 'vesta' | 'aparat_telefon' | 'alte'; + +export interface InventoryItem { + id: string; + sku: string; + name: string; + type: InventoryItemType; + size: string | null; + color: string | null; + pricePerUnit: string | null; + stockQty: number; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface PaginatedInventory { + total: number; + page: number; + limit: number; + items: InventoryItem[]; +} + +export interface CimServiceCategory { + id: string; + contractId: string; + categorieId: string; + tipRemunerare: 'tarif' | 'procent'; + sumaNeta: string | null; + procent: string | null; +} + +export interface EmploymentContract { + id: string; + nrCim: string; + employeeId: string; + categorie: ContractCategory; + dataSemnarii: string; + dataAngajarii: string; + dataDemisiei: string | null; + perioada: ContractPeriod; + dataTerminarii: string | null; + functiaClasificator: string | null; + codFunctie: string | null; + functiaOrganigrama: string | null; + tipCim: ContractType; + departmentId: string; + department: Department; + regimMunca: string | null; + tipSalarizare: SalaryType | null; + salarizareDetails: unknown; + clausaAditionala: unknown; + workScheduleId: string | null; + workSchedule: WorkSchedule | null; + categoriiServicii: CimServiceCategory[]; + createdAt: string; + updatedAt: string; +} + +export interface ContractListItem extends EmploymentContract { + status: 'activ' | 'expirat' | 'expira_in_curand'; + employee: Pick; +} + +export interface PaginatedContracts { + total: number; + page: number; + limit: number; + items: ContractListItem[]; +} + +// ─── Core Employee ──────────────────────────────────────────── + +export interface Employee { + id: string; + idnp: string; + nume: string; + prenume: string; + patronimic: string | null; + numeAnterior: string | null; + dataNasterii: string; + domiciliu: string; + adresaReala: string | null; + telefonPersonal: string; + telefonServiciu: string | null; + emailPersonal: string | null; + emailCorporativ: string | null; + sex: Sex; + codCpas: string | null; + stareCivila: MaritalStatus | null; + titluStiintific: ScientificTitle | null; + titluUniversitar: string | null; + status: EmployeeStatus; + gradDizabilitateId: string | null; + gradDizabilitate: DisabilityGrade | null; + recomandareInternaId: string | null; + recomandareInterna: Pick | null; + identityDocuments: IdentityDocument[]; + familyMembers: FamilyMember[]; + educations: Education[]; + qualifications: Qualification[]; + trainings: Training[]; + disciplinarySanctions: DisciplinarySanction[]; + benefit: Benefit | null; + contracts: EmploymentContract[]; + createdAt: string; + updatedAt: string; +} + +// ─── Paginated list response ────────────────────────────────── + +export interface PaginatedEmployees { + total: number; + page: number; + limit: number; + items: EmployeeListItem[]; +} + +export interface EmployeeListItem { + id: string; + idnp: string; + nume: string; + prenume: string; + sex: Sex; + status: EmployeeStatus; + dataNasterii: string; + telefonPersonal: string; + emailCorporativ: string | null; + contracts: { + functiaOrganigrama: string | null; + department: { name: string }; + }[]; +} + +// ─── Phase 5: Medical Control ───────────────────────────────── + +export type MedicalCheckupType = + | 'la_angajare' + | 'periodic' + | 'la_reluarea_activitatii' + | 'la_incetarea_expunerii' + | 'suplimentar'; + +export type MedicalVerdict = + | 'apt' + | 'apt_perioada_adaptare' + | 'apt_conditionat' + | 'inapt_temporar' + | 'inapt'; + +export interface RiskFactors { + chimici?: string[]; + fizici?: string[]; + biologici?: string[]; + ergonomici?: string[]; + psihosociali?: string[]; +} + +export type RiskExposureType = + | 'AGENT_CHIMIC' | 'PULBERI' | 'AGENT_BIOLOGIC' | 'ZGOMOT' + | 'VIBRATII' | 'CAMP_ELECTROMAGNETIC' | 'RADIATII_OPTICE'; + +export interface RiskExposure { + id?: string; + tip: RiskExposureType; + denumire: string; + cas?: string | null; + einecs?: string | null; + clasificare?: string | null; + zonaAfectata?: string | null; + timpExpunere?: string | null; + vep?: string | null; + vlep?: string | null; + caracteristici?: string | null; + procesVerbal?: string | null; +} + +export interface WorkplaceRiskCard { + id: string; + name: string; + riskFactors: RiskFactors | null; + // Antet Anexa 4 + filiala?: string | null; + adresaFiliala?: string | null; + telefonFiliala?: string | null; + caemPrimeleDouaCifre?: string | null; + cormSubgrupaMajora?: string | null; + directiaSectiaSectorul?: string | null; + numarulLoculuiDeMunca?: string | null; + caemDiviziune?: string | null; + clasaConditiilorDeMunca?: string | null; + numarLucratoriPosibili?: number | null; + tipFisa?: string; // STANDARD | DISTANTA_DIGITAL + // Bloc descriptiv + subsol + evaluareDetalii?: Record | null; + anexeIgienicoSanitare?: Record | null; + mijloaceProtectieColectiva?: string | null; + mijloaceProtectieIndividuala?: string | null; + echipamentLucru?: string | null; + observatii?: string | null; + // Radiații ionizante (per loc de muncă) + radiatiiIonizante?: boolean | null; + radiatiiGrupa?: string | null; + radiatiiAparatura?: string | null; + radiatiiSurse?: string | null; + radiatiiTipExpunere?: string | null; + radiatiiMasuriProtectie?: string | null; + exposures?: RiskExposure[]; + _count?: { profiles: number }; + profiles?: { + id: string; + employee: { id: string; idnp: string; nume: string; prenume: string }; + }[]; + createdAt: string; + updatedAt: string; +} + +export type OverexposureKind = 'EXCEPTIONALA' | 'ACCIDENTALA'; + +export interface RadiationOverexposure { + id?: string; + fel: OverexposureKind; + tipExpunere?: string | null; + data?: string | null; + dozaMsv?: number | string | null; +} + +export interface EmployeeMedicalProfile { + id: string; + employeeId: string; + ocupatieCorm: string | null; + workplaceRiskCardId: string | null; + workplaceRiskCard: WorkplaceRiskCard | null; + dataUltimControlMedical: string | null; + expusRadiatiiIonizante: boolean; + dataIntrarii: string | null; + expunereAnterioaraPerioda: string | null; + expunereAnterioaraAni: number | null; + dozaCumulataExternaMsv: string | null; + dozaCumulataInternaMsv: string | null; + dozaTotalaMsv?: number; + overexposures?: RadiationOverexposure[]; + createdAt: string; + updatedAt: string; +} + +export interface GeneratedDoc { + name: string; + url: string; + type: string; +} + +export interface MedicalCheckup { + id: string; + employeeId: string; + tip: MedicalCheckupType; + dataPlanificata: string; + dataEfectuata: string | null; + verdict: MedicalVerdict | null; + recomandari: string | null; + valabilPanaLa: string | null; + semnatDe: string | null; + documenteGenerate: GeneratedDoc[] | null; + employee?: Pick & { + medicalProfile?: EmployeeMedicalProfile | null; + }; + createdAt: string; + updatedAt: string; +} + +export interface UpcomingExpiration { + id: string; + employeeId: string; + dataUltimControlMedical: string | null; + expusRadiatiiIonizante: boolean; + employee: { + id: string; + idnp: string; + nume: string; + prenume: string; + contracts: { department: { name: string } }[]; + }; + workplaceRiskCard: { id: string; name: string } | null; +} + +// ─── Dashboard ─────────────────────────────────────────────── + +export interface DashboardStats { + employees: { total: number; activ: number; concediat: number; suspendat: number }; + activeContracts: number; + recentHires: number; + activeSanctions: number; + expirations: { + contractsDeterminata: Array<{ + id: string; + nrCim: string; + dataTerminarii: string; + employee: { id: string; nume: string; prenume: string; idnp: string }; + department: { name: string }; + }>; + expiringDocs: Array<{ + id: string; + tipAct: string; + dataExpirarii: string; + employee: { id: string; nume: string; prenume: string; idnp: string }; + }>; + upcomingCheckups: Array<{ + id: string; + tip: string; + dataPlanificata: string; + employee: { id: string; nume: string; prenume: string; idnp: string }; + }>; + expiringQualifications: Array<{ + id: string; + categorie: string; + dataExpirarii: string; + employee: { id: string; nume: string; prenume: string; idnp: string }; + }>; + }; +} + +export interface BulkInitiateResult { + batchId: string; + groupsCount: number; + employeesCount: number; + checkups: { + employeeId: string; + checkupId: string; + documents: GeneratedDoc[]; + }[]; +} + +export interface BulkDocumentContext { + telefon?: string; + fax?: string; + email?: string; + solicitant?: string; + functia?: string; +} + + + +// ─── Anexa Templates ───────────────────────────────────────── + +export type AnexaType = 'ANEXA_3' | 'ANEXA_4' | 'ANEXA_4B' | 'ANEXA_6'; + +export interface AnexaTemplateMeta { + id: string; + type: AnexaType; + name: string; + updatedById: string; + updatedAt: string; +} + +export interface AnexaTemplate extends AnexaTemplateMeta { + contentJson: unknown; +} + +export interface AnexaTemplateVersion { + id: string; + templateId: string; + contentJson: unknown; + savedById: string; + savedAt: string; + label: string | null; +} + +export interface PreviewEmployee { + id: string; + idnp: string; + nume: string; + prenume: string; + dataNasterii: string; + contracts: { + functiaOrganigrama: string | null; + functiaClasificator: string | null; + department: { name: string }; + }[]; + medicalProfile: { + ocupatieCorm: string | null; + dozaCumulataExternaMsv: string | null; + dozaCumulataInternaMsv: string | null; + } | null; +} diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json new file mode 100644 index 0000000..77860e3 --- /dev/null +++ b/apps/web/src/i18n/en.json @@ -0,0 +1,39 @@ +{ + "nav": { + "employees": "Employees", + "departments": "Departments", + "contracts": "Contracts", + "evaluation": "Evaluation", + "medical": "Medical control" + }, + "employees": { + "title": "Employees", + "add": "Add employee", + "search": "Search by name, surname, IDNP...", + "columns": { + "idnp": "IDNP", + "name": "Name", + "position": "Position", + "department": "Department", + "status": "Status", + "phone": "Phone" + }, + "status": { + "activ": "Active", + "concediat": "Dismissed", + "suspendat": "Suspended" + } + }, + "actions": { + "save": "Save", + "cancel": "Cancel", + "edit": "Edit", + "delete": "Delete", + "view": "View" + }, + "validation": { + "idnp_invalid": "Invalid IDNP (13 digits, wrong check digit)", + "required": "Required field", + "email_invalid": "Invalid email address" + } +} diff --git a/apps/web/src/i18n/i18n.ts b/apps/web/src/i18n/i18n.ts new file mode 100644 index 0000000..52b17c6 --- /dev/null +++ b/apps/web/src/i18n/i18n.ts @@ -0,0 +1,14 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import ro from './ro.json'; + +i18n + .use(initReactI18next) + .init({ + resources: { ro: { translation: ro } }, + lng: 'ro', + fallbackLng: 'ro', + interpolation: { escapeValue: false }, + }); + +export default i18n; diff --git a/apps/web/src/i18n/ro.json b/apps/web/src/i18n/ro.json new file mode 100644 index 0000000..e49b68e --- /dev/null +++ b/apps/web/src/i18n/ro.json @@ -0,0 +1,44 @@ +{ + "nav": { + "dashboard": "Dashboard", + "employees": "Angajați", + "departments": "Departamente", + "contracts": "Contracte", + "inventory": "Inventar", + "evaluation": "Evaluare", + "risk_cards": "Carduri de risc", + "medical": "Control medical", + "inbox": "Inbox medic" + }, + "employees": { + "title": "Angajați", + "add": "Adaugă angajat", + "search": "Caută după nume, prenume, IDNP...", + "columns": { + "idnp": "IDNP", + "name": "Nume", + "position": "Funcție", + "department": "Departament", + "status": "Statut", + "phone": "Telefon" + }, + "status": { + "all": "Toate statutele", + "activ": "Activ", + "concediat": "Concediat", + "suspendat": "Suspendat" + } + }, + "actions": { + "save": "Salvează", + "cancel": "Anulează", + "edit": "Editează", + "delete": "Șterge", + "view": "Vizualizează" + }, + "validation": { + "idnp_invalid": "IDNP invalid (13 cifre, cifra de control incorectă)", + "required": "Câmp obligatoriu", + "email_invalid": "Adresă email invalidă" + } +} diff --git a/apps/web/src/i18n/ru.json b/apps/web/src/i18n/ru.json new file mode 100644 index 0000000..9078b36 --- /dev/null +++ b/apps/web/src/i18n/ru.json @@ -0,0 +1,39 @@ +{ + "nav": { + "employees": "Сотрудники", + "departments": "Отделы", + "contracts": "Контракты", + "evaluation": "Оценка", + "medical": "Медконтроль" + }, + "employees": { + "title": "Сотрудники", + "add": "Добавить сотрудника", + "search": "Поиск по имени, фамилии, IDNP...", + "columns": { + "idnp": "IDNP", + "name": "Фамилия", + "position": "Должность", + "department": "Отдел", + "status": "Статус", + "phone": "Телефон" + }, + "status": { + "activ": "Активен", + "concediat": "Уволен", + "suspendat": "Приостановлен" + } + }, + "actions": { + "save": "Сохранить", + "cancel": "Отмена", + "edit": "Редактировать", + "delete": "Удалить", + "view": "Просмотр" + }, + "validation": { + "idnp_invalid": "Некорректный IDNP (13 цифр, неверная контрольная цифра)", + "required": "Обязательное поле", + "email_invalid": "Некорректный email" + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7715dd8 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { MantineProvider, createTheme } from '@mantine/core'; +import { Notifications } from '@mantine/notifications'; +import { ModalsProvider } from '@mantine/modals'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@mantine/core/styles.css'; +import '@mantine/notifications/styles.css'; +import '@mantine/dates/styles.css'; +import './styles/global.css'; +import './i18n/i18n'; +import App from './App'; + +// Medpark brand teal shades for Mantine (centered on #008286) +const medparkTeal: [string, string, string, string, string, string, string, string, string, string] = [ + '#e6f4f4', // 0 — lightest + '#ccebeb', + '#99d7d8', + '#66c3c5', + '#33afb2', + '#009b9f', + '#008286', // 6 ← brand primary + '#006b6e', + '#005457', + '#003d3f', // 9 — darkest +]; + +const theme = createTheme({ + fontFamily: "'Montserrat', Arial, sans-serif", + fontFamilyMonospace: "'Courier New', monospace", + primaryColor: 'medpark', + colors: { medpark: medparkTeal }, + defaultRadius: 'sm', + fontSizes: { + xs: '0.8rem', + sm: '0.9375rem', // 15px — base for most UI text + md: '1.0625rem', // 17px + lg: '1.1875rem', // 19px + xl: '1.3125rem', // 21px + }, + lineHeights: { + xs: '1.5', + sm: '1.55', + md: '1.6', + lg: '1.65', + xl: '1.7', + }, + components: { + Button: { + defaultProps: { radius: 'sm', size: 'sm' }, + styles: { root: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 } }, + }, + TextInput: { + defaultProps: { size: 'sm' }, + styles: { input: { fontFamily: "'Montserrat', Arial, sans-serif" } }, + }, + Select: { + defaultProps: { size: 'sm' }, + styles: { input: { fontFamily: "'Montserrat', Arial, sans-serif" } }, + }, + DateInput: { + defaultProps: { size: 'sm' }, + }, + Table: { + styles: { table: { fontFamily: "'Montserrat', Arial, sans-serif", fontSize: '0.9rem' } }, + }, + Badge: { + styles: { root: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 } }, + }, + Tabs: { + styles: { tab: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500, fontSize: '0.9rem' } }, + }, + Modal: { + defaultProps: { size: 'lg' }, + }, + Drawer: { + defaultProps: { size: 'xl' }, + }, + }, +}); + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + , +); diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..029f6e7 --- /dev/null +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { Box, Button, Select, Text, TextInput, Alert, Group, Stack } from '@mantine/core'; +import { apiClient } from '../../api/client'; + +const font = "'Montserrat', Arial, sans-serif"; +const teal = '#008286'; + +const ROLES = [ + { value: 'hr_admin', label: 'HR Admin' }, + { value: 'hr_specialist', label: 'HR Specialist' }, + { value: 'nursing_director', label: 'Nursing Director' }, + { value: 'quality_auditor', label: 'Quality Auditor' }, + { value: 'manager', label: 'Manager' }, + { value: 'medic_familie', label: 'Medic Familie' }, + { value: 'employee', label: 'Angajat' }, +]; + +interface Props { + onLogin: () => void; +} + +export function LoginPage({ onLogin }: Props) { + const [username, setUsername] = useState('admin'); + const [role, setRole] = useState('hr_admin'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async () => { + setLoading(true); + setError(null); + try { + const res = await apiClient.post<{ token: string; username: string; role: string }>( + '/auth/dev-login', + { username, role }, + ); + localStorage.setItem('kc_token', res.data.token); + localStorage.setItem('kc_username', res.data.username); + localStorage.setItem('kc_role', res.data.role); + onLogin(); + } catch { + setError('Nu s-a putut autentifica. Verificați că API-ul rulează.'); + } finally { + setLoading(false); + } + }; + + return ( + + + {/* Logo */} + + Medpark International Hospital { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + + HRM — Sistem de management HR + + + Mod dezvoltare — autentificare locală + + + + {error && ( + + {error} + + )} + + + setUsername(e.currentTarget.value)} + placeholder="admin" + styles={{ + label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' }, + input: { fontFamily: font }, + }} + /> + + + + + + {isLoading ? ( +
+ ) : ( + + + + + Nr. CIM + Angajat + Funcția + Secția + Perioadă + Data angajării + Data terminării + Salarizare + Status + + + + {(data?.items ?? []).map((c) => ( + openRow(c)} + style={{ cursor: 'pointer', borderBottom: '1px solid #e9ecef' }} + > + {c.nrCim} + + { e.stopPropagation(); navigate(`/employees/${c.employee.id}`); }} + style={{ fontFamily: font, color: teal, fontWeight: 500 }} + > + {c.employee.nume} {c.employee.prenume} + + + {c.functiaOrganigrama ?? '—'} + {c.department.name} + {PERIOD_LABEL[c.perioada]} + {dayjs(c.dataAngajarii).format('DD.MM.YYYY')} + + {c.dataTerminarii + ? dayjs(c.dataTerminarii).format('DD.MM.YYYY') + : c.dataDemisiei + ? dayjs(c.dataDemisiei).format('DD.MM.YYYY') + : '—'} + + {c.tipSalarizare ?? '—'} + + + {STATUS_LABEL[c.status]} + + + + ))} + {(data?.items ?? []).length === 0 && ( + + + + Niciun contract găsit. + + + + )} + +
+
+ )} + + setAddModalOpen(false)} + title={Selectează angajatul} + size="md" + > + setEditVal(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') cancelRename(); + }} + onBlur={commitRename} + style={{ + flex: 1, + fontFamily: font, + fontWeight: level === 0 ? 500 : 300, + fontSize: '0.875rem', + color: charcoal, + border: `1px solid ${teal}`, + borderRadius: 4, + padding: '2px 8px', + outline: 'none', + background: '#fff', + }} + /> + ) : ( + + {dept.name} + + )} + + {/* Actions */} + {editing ? ( + + { e.preventDefault(); commitRename(); }}> + + + { e.preventDefault(); cancelRename(); }}> + + + + ) : ( + + {!dragMode && ( + + { e.stopPropagation(); setEditVal(dept.name); setEditing(true); }} + > + + + + )} + + { e.stopPropagation(); onDelete(dept); }} + > + + + + + )} + + + {expanded && dept.children?.map((child) => ( + + ))} + + ); +} + +// ── Page ───────────────────────────────────────────────────── + +interface DeptFormValues { name: string; parentId?: string } + +function findName(depts: Department[], id: string): string { + for (const d of depts) { + if (d.id === id) return d.name; + const hit = findName(d.children ?? [], id); + if (hit) return hit; + } + return ''; +} + +// Returns the parentId of the node with given id (null = root-level) +function findParentId(depts: Department[], id: string, parentId: string | null = null): string | null | undefined { + for (const d of depts) { + if (d.id === id) return parentId; + const hit = findParentId(d.children ?? [], id, d.id); + if (hit !== undefined) return hit; + } + return undefined; +} + +export function DepartmentsPage() { + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [dragMode, setDragMode] = useState(false); + const [activeId, setActiveId] = useState(null); + const [moveHistory, setMoveHistory] = useState<{ id: string; name: string; prevParentId: string | null }[]>([]); + + const { data, isLoading } = useQuery({ + queryKey: ['departments'], + queryFn: () => apiClient.get('/departments').then((r) => r.data), + }); + + const { data: flat } = useQuery({ + queryKey: ['ref', 'departments-flat'], + queryFn: () => apiClient.get('/reference/departments/flat').then((r) => r.data), + staleTime: 300_000, + }); + + const { register, handleSubmit, reset, setValue, watch, formState: { isSubmitting } } = useForm(); + + const createMutation = useMutation({ + mutationFn: (d: DeptFormValues) => + apiClient.post('/departments', { ...d, parentId: d.parentId || undefined }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['departments'] }); + void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] }); + notifications.show({ color: 'medpark', title: 'Creat', message: 'Departament adăugat.' }); + setModalOpen(false); + reset(); + }, + onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut crea.' }), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/departments/${id}`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['departments'] }); + void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] }); + notifications.show({ color: 'medpark', title: 'Șters', message: 'Departamentul a fost șters.' }); + setDeleteTarget(null); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la ștergere.'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + setDeleteTarget(null); + }, + }); + + const moveMutation = useMutation({ + mutationFn: ({ id, parentId }: { id: string; parentId: string | null }) => + apiClient.patch(`/departments/${id}`, { parentId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['departments'] }); + void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] }); + notifications.show({ color: 'medpark', title: 'Reorganizat', message: 'Structura departamentelor actualizată.' }); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la mutare.'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + }, + }); + + const undoMutation = useMutation({ + mutationFn: ({ id, prevParentId }: { id: string; prevParentId: string | null }) => + apiClient.patch(`/departments/${id}`, { parentId: prevParentId }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['departments'] }); + void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] }); + setMoveHistory((h) => h.slice(0, -1)); + notifications.show({ color: 'gray', title: 'Anulat', message: 'Ultima mutare a fost anulată.' }); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la anulare.'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + }, + }); + + return ( + + + + + Departamente + + + + + {dragMode && moveHistory.length > 0 && ( + + + + )} + + {!dragMode && ( + + )} + + + + setActiveId(active.id as string)} + onDragEnd={({ active, over }) => { + setActiveId(null); + if (!over || active.id === over.id) return; + const id = active.id as string; + const prevParentId = findParentId(data ?? [], id) ?? null; + const name = findName(data ?? [], id); + setMoveHistory((h) => [...h, { id, name, prevParentId }]); + moveMutation.mutate({ + id, + parentId: over.id === '__root__' ? null : over.id as string, + }); + }} + onDragCancel={() => setActiveId(null)} + > + + {/* Header */} +
+ + Denumire + +
+ + {isLoading ? ( +
+ ) : ( +
+ + {!data?.length ? ( +
+ Niciun departament. Adăugați primul departament. +
+ ) : ( + data.map((dept) => ( + + )) + )} +
+ )} +
+ + + {activeId && data ? ( +
+ + {findName(data, activeId)} +
+ ) : null} +
+
+ + {/* Create Modal */} + { setModalOpen(false); reset(); }} + title={Departament nou} + styles={{ header: { borderBottom: `2px solid ${teal}` } }} + > + + +
createMutation.mutate(d))}> + + + { setStatus(v); setPage(1); }} + style={{ width: 160 }} + styles={{ + input: { fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }, + }} + /> + + + + {/* ── Table ── */} + + {isLoading ? ( +
+ ) : ( + <> + + + + + {[ + t('employees.columns.idnp'), + t('employees.columns.name'), + t('employees.columns.position'), + t('employees.columns.department'), + t('employees.columns.phone'), + t('employees.columns.status'), + '', + ].map((col, i) => ( + + ))} + + + + {!data?.items.length ? ( + + + + ) : ( + data.items.map((emp, idx) => { + const st = STATUS_CONFIG[emp.status]; + return ( + navigate(`/employees/${emp.id}`)} + style={{ + background: idx % 2 === 0 ? '#ffffff' : '#fafafa', + cursor: 'pointer', + transition: 'background 0.1s', + }} + onMouseEnter={(e) => + ((e.currentTarget as HTMLElement).style.background = '#e6f4f4') + } + onMouseLeave={(e) => + ((e.currentTarget as HTMLElement).style.background = + idx % 2 === 0 ? '#ffffff' : '#fafafa') + } + > + + + + + + + + + ); + }) + )} + +
+ {col} +
+ Niciun angajat găsit +
+ {emp.idnp} + + {emp.nume} {emp.prenume} + + {emp.contracts[0]?.functiaOrganigrama ?? ( + + )} + + {emp.contracts[0]?.department.name ?? ( + + )} + + {emp.telefonPersonal} + + + {st.label_ro} + + + + + → + + +
+
+ + {/* Footer: count + pagination */} + {(data?.total ?? 0) > 0 && ( + + + {data?.total} angajați total + + {totalPages > 1 && ( + + )} + + )} + + )} +
+ + setDrawerOpen(false)} + /> +
+ ); +} diff --git a/apps/web/src/pages/employees/components/EmployeeDrawer.tsx b/apps/web/src/pages/employees/components/EmployeeDrawer.tsx new file mode 100644 index 0000000..8b4e25a --- /dev/null +++ b/apps/web/src/pages/employees/components/EmployeeDrawer.tsx @@ -0,0 +1,367 @@ +import { useEffect } from 'react'; +import { + Drawer, TextInput, Select, SegmentedControl, Button, + Group, Stack, Box, Text, Loader, LoadingOverlay, +} from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import dayjs from 'dayjs'; +import { apiClient } from '../../../api/client'; +import type { Employee, DisabilityGrade } from '../../../api/types'; +import { employeeSchema, type EmployeeFormValues } from '../employeeSchema'; + +const font = "'Montserrat', Arial, sans-serif"; +const teal = '#008286'; +const charcoal = '#58595b'; + +function idnpValid(v: string): boolean { + if (!/^\d{13}$/.test(v)) return false; + const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1]; + const sum = weights.reduce((acc, w, i) => acc + w * parseInt(v[i], 10), 0); + return (sum % 10) === parseInt(v[12], 10); +} + +function SectionLabel({ children }: { children: string }) { + return ( + + + {children} + + + ); +} + +interface Props { + opened: boolean; + onClose: () => void; + employeeId?: string; +} + +export function EmployeeDrawer({ opened, onClose, employeeId }: Props) { + const isEdit = !!employeeId; + const qc = useQueryClient(); + + const { data: existing, isLoading: loadingExisting } = useQuery({ + queryKey: ['employee', employeeId], + queryFn: () => apiClient.get(`/employees/${employeeId}`).then((r) => r.data), + enabled: isEdit && opened, + staleTime: 30_000, + }); + + const { data: grades } = useQuery({ + queryKey: ['ref', 'disability-grades'], + queryFn: () => apiClient.get('/reference/disability-grades').then((r) => r.data), + staleTime: 300_000, + }); + + const { data: employeesRes } = useQuery({ + queryKey: ['employees', 'list-all'], + queryFn: () => apiClient.get<{items: Employee[]}>('/employees?limit=1000').then((r) => r.data), + staleTime: 60_000, + }); + const allEmployees = employeesRes?.items ?? []; + + const { + register, + handleSubmit, + control, + watch, + reset, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(employeeSchema) }); + + useEffect(() => { + if (existing && isEdit) { + reset({ + idnp: existing.idnp, + nume: existing.nume, + prenume: existing.prenume, + patronimic: existing.patronimic ?? '', + numeAnterior: existing.numeAnterior ?? '', + dataNasterii: existing.dataNasterii.slice(0, 10), + domiciliu: existing.domiciliu, + adresaReala: existing.adresaReala ?? '', + telefonPersonal: existing.telefonPersonal, + telefonServiciu: existing.telefonServiciu ?? '', + emailPersonal: existing.emailPersonal ?? '', + emailCorporativ: existing.emailCorporativ ?? '', + sex: existing.sex, + stareCivila: existing.stareCivila ?? undefined, + codCpas: existing.codCpas ?? '', + gradDizabilitateId: existing.gradDizabilitateId ?? '', + recomandareInternaId: existing.recomandareInternaId ?? '', + titluStiintific: existing.titluStiintific ?? undefined, + titluUniversitar: existing.titluUniversitar ?? '', + status: existing.status ?? 'activ', + }); + } else if (!isEdit) { + reset({}); + } + }, [existing, isEdit, reset]); + + const mutation = useMutation({ + mutationFn: (data: EmployeeFormValues) => { + const payload = { + ...data, + gradDizabilitateId: data.gradDizabilitateId || undefined, + recomandareInternaId: data.recomandareInternaId || undefined, + telefonServiciu: data.telefonServiciu || undefined, + emailPersonal: data.emailPersonal || undefined, + emailCorporativ: data.emailCorporativ || undefined, + patronimic: data.patronimic || undefined, + numeAnterior: data.numeAnterior || undefined, + adresaReala: data.adresaReala || undefined, + codCpas: data.codCpas || undefined, + titluUniversitar: data.titluUniversitar || undefined, + }; + return isEdit + ? apiClient.patch(`/employees/${employeeId}`, payload) + : apiClient.post('/employees', payload); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['employees'] }); + void qc.invalidateQueries({ queryKey: ['dashboard-stats'] }); + if (isEdit) void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); + notifications.show({ + color: 'medpark', + title: isEdit ? 'Salvat' : 'Angajat creat', + message: isEdit ? 'Modificările au fost salvate.' : 'Angajatul a fost adăugat.', + }); + onClose(); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare necunoscută'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + }, + }); + + const idnp = watch('idnp') ?? ''; + const idnpOk = idnpValid(idnp); + const idnpIndicator = idnp.length === 0 ? null : idnpOk ? '✓' : '✗'; + + return ( + + {isEdit ? 'Editează angajat' : 'Angajat nou'} + + } + position="right" + size="xl" + styles={{ + header: { borderBottom: `2px solid ${teal}` }, + body: { padding: '0 24px 24px' }, + }} + > + {isEdit && loadingExisting ? ( + + + + ) : ( + + + + mutation.mutate(d))}> + {/* ── Date personale ── */} + Date personale + + + {idnpIndicator} + + ) + } + styles={{ input: { fontFamily: "'Courier New', monospace", letterSpacing: '0.1em' }, label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} + {...register('idnp')} + /> + + + + + + + + + + + + + ( + field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} + error={errors.dataNasterii?.message} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} + /> + )} + /> + + Sex * + ( + + )} + /> + {errors.sex && {errors.sex.message}} + + + + ( + e.id !== employeeId) // prevent self-recommendation visually + .map((e) => ({ value: e.id, label: `${e.nume} ${e.prenume} (${e.idnp})` }))} + value={field.value || null} + onChange={(v) => field.onChange(v ?? '')} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} + /> + )} + /> + + ( + field.onChange(v ?? undefined)} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} + /> + )} + /> + + + + {isEdit && ( + ( + field.onChange(v)} + searchable + clearable + nothingFoundMessage="Niciun articol disponibil" + styles={selectStyles} + /> + )} /> + ( + field.onChange(v)} + searchable + clearable + nothingFoundMessage="Niciun articol disponibil" + styles={selectStyles} + /> + )} /> + ( + field.onChange(v)} + searchable + clearable + nothingFoundMessage="Niciun aparat disponibil" + styles={selectStyles} + /> + )} /> + + + + + + + + + + + +
+ + ); +} diff --git a/apps/web/src/pages/employees/drawers/ContractDrawer.tsx b/apps/web/src/pages/employees/drawers/ContractDrawer.tsx new file mode 100644 index 0000000..34ee417 --- /dev/null +++ b/apps/web/src/pages/employees/drawers/ContractDrawer.tsx @@ -0,0 +1,426 @@ +import { useEffect, useState } from 'react'; +import { + Drawer, Button, Group, Stack, Text, LoadingOverlay, Box, + Select, TextInput, NumberInput, SegmentedControl, Divider, ActionIcon, Textarea, +} from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import dayjs from 'dayjs'; +import { apiClient } from '../../../api/client'; +import type { EmploymentContract, Department, WorkSchedule } from '../../../api/types'; + +const font = "'Montserrat', Arial, sans-serif"; +const teal = '#008286'; +const charcoal = '#58595b'; + +const schema = z.object({ + nrCim: z.string().min(1, 'Câmp obligatoriu'), + categorie: z.enum(['principal', 'secundar']), + dataSemnarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + dataAngajarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + dataDemisiei: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), + perioada: z.enum(['determinata', 'nedeterminata', 'replasare_temporara']), + dataTerminarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), + functiaClasificator: z.string().optional(), + codFunctie: z.string().optional(), + functiaOrganigrama: z.string().optional(), + tipCim: z.enum(['de_baza', 'cumul']), + departmentId: z.string().uuid('Selectați departamentul'), + regimMunca: z.string().optional(), + tipSalarizare: z.enum(['fix', 'pe_ore', 'in_acord']).optional(), + workScheduleId: z.string().uuid().optional().or(z.literal('')), + zileConcediu: z.number().int().min(0).max(365).optional(), +}); +type FormValues = z.infer; + +interface ServiceCatRow { categorieId: string; tipRemunerare: 'tarif' | 'procent'; sumaNeta: string; procent: string } +const emptyRow = (): ServiceCatRow => ({ categorieId: '', tipRemunerare: 'tarif', sumaNeta: '', procent: '' }); + +interface ClausaRow { titlu: string; text: string } +const emptyClausaRow = (): ClausaRow => ({ titlu: '', text: '' }); + +interface Props { + employeeId: string; + record?: EmploymentContract; + opened: boolean; + onClose: () => void; +} + +export function ContractDrawer({ employeeId, record, opened, onClose }: Props) { + const isEdit = !!record; + const qc = useQueryClient(); + const [serviceCats, setServiceCats] = useState([]); + const [clauze, setClauze] = useState([]); + + const { data: depts } = useQuery({ + queryKey: ['ref', 'departments-flat'], + queryFn: () => apiClient.get('/reference/departments/flat').then((r) => r.data), + staleTime: 300_000, + }); + + const { data: schedules } = useQuery({ + queryKey: ['ref', 'work-schedules'], + queryFn: () => apiClient.get('/reference/work-schedules').then((r) => r.data), + staleTime: 300_000, + }); + + const { handleSubmit, control, reset, watch, formState: { errors, isSubmitting } } = + useForm({ resolver: zodResolver(schema) }); + + const perioadaValue = watch('perioada'); + + useEffect(() => { + if (record) { + reset({ + nrCim: record.nrCim, + categorie: record.categorie, + dataSemnarii: record.dataSemnarii.slice(0, 10), + dataAngajarii: record.dataAngajarii.slice(0, 10), + dataDemisiei: record.dataDemisiei?.slice(0, 10) ?? '', + perioada: record.perioada, + dataTerminarii: record.dataTerminarii?.slice(0, 10) ?? '', + functiaClasificator: record.functiaClasificator ?? '', + codFunctie: record.codFunctie ?? '', + functiaOrganigrama: record.functiaOrganigrama ?? '', + tipCim: record.tipCim, + departmentId: record.departmentId, + regimMunca: record.regimMunca ?? '', + tipSalarizare: record.tipSalarizare ?? undefined, + workScheduleId: record.workSchedule?.id ?? '', + zileConcediu: (record.salarizareDetails as { zileConcediu?: number } | null)?.zileConcediu ?? undefined, + }); + setServiceCats(record.categoriiServicii.map((c) => ({ + categorieId: c.categorieId, + tipRemunerare: c.tipRemunerare as 'tarif' | 'procent', + sumaNeta: c.sumaNeta?.toString() ?? '', + procent: c.procent?.toString() ?? '', + }))); + const cl = (record.clausaAditionala as { clauze?: ClausaRow[] } | null)?.clauze; + setClauze(Array.isArray(cl) ? cl.map(c => ({ titlu: c.titlu ?? '', text: c.text ?? '' })) : []); + } else { + reset({ + categorie: 'principal', + tipCim: 'de_baza', + perioada: 'nedeterminata', + dataSemnarii: dayjs().format('YYYY-MM-DD'), + dataAngajarii: dayjs().format('YYYY-MM-DD'), + }); + setServiceCats([]); + setClauze([]); + } + }, [record, opened, reset]); + + const mutation = useMutation({ + mutationFn: (data: FormValues) => { + const { zileConcediu, ...rest } = data; + const payload = { + ...rest, + dataDemisiei: data.dataDemisiei || undefined, + dataTerminarii: data.dataTerminarii || undefined, + functiaClasificator: data.functiaClasificator || undefined, + codFunctie: data.codFunctie || undefined, + functiaOrganigrama: data.functiaOrganigrama || undefined, + regimMunca: data.regimMunca || undefined, + workScheduleId: data.workScheduleId || undefined, + salarizareDetails: zileConcediu != null ? { zileConcediu } : undefined, + clausaAditionala: clauze.filter(c => c.titlu.trim() || c.text.trim()).length + ? { clauze: clauze.filter(c => c.titlu.trim() || c.text.trim()) } + : undefined, + categoriiServicii: serviceCats + .filter((r) => r.categorieId) + .map((r) => ({ + categorieId: r.categorieId, + tipRemunerare: r.tipRemunerare, + sumaNeta: r.tipRemunerare === 'tarif' && r.sumaNeta ? Number(r.sumaNeta) : undefined, + procent: r.tipRemunerare === 'procent' && r.procent ? Number(r.procent) : undefined, + })), + }; + return isEdit + ? apiClient.patch(`/employees/${employeeId}/contracts/${record.id}`, payload) + : apiClient.post(`/employees/${employeeId}/contracts`, payload); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); + void qc.invalidateQueries({ queryKey: ['contracts'] }); + notifications.show({ color: 'medpark', title: 'Salvat', message: isEdit ? 'Contract actualizat.' : 'Contract creat.' }); + onClose(); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare'; + notifications.show({ color: 'red', title: 'Eroare', message: msg }); + }, + }); + + const deptData = (depts ?? []).map((d) => ({ value: d.id, label: d.name })); + const scheduleData = [ + { value: '', label: '— Fără program fix —' }, + ...(schedules ?? []).map((s) => ({ value: s.id, label: s.name })), + ]; + + const section = (label: string) => ( + {label}} + labelPosition="left" my={12} + /> + ); + + return ( + {isEdit ? 'Editare contract' : 'Contract nou'}} + styles={{ header: { borderBottom: `2px solid ${teal}` } }} + > + + + +
mutation.mutate(d))}> + + + {section('Date contract')} + + ( + + )} /> + ( + + Categorie * + + + )} /> + + + + ( + + Tip CIM * + + + )} /> + ( + field.onChange(v ?? '')} + error={errors.departmentId?.message} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} /> + )} /> + + + ( + + )} /> + ( + + )} /> + + + + ( + + )} /> + ( + + )} /> + + + {section('Salarizare și program')} + + ( + field.onChange(v ?? '')} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} /> + )} /> + + ( + field.onChange(typeof v === 'number' ? v : undefined)} + error={errors.zileConcediu?.message} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' }, description: { fontFamily: font } }} + style={{ maxWidth: 240 }} + /> + )} /> + + {section('Categorii servicii')} + + {serviceCats.map((row, i) => ( + + setServiceCats((prev) => prev.map((r, j) => j === i ? { ...r, categorieId: e.currentTarget.value } : r))} + style={{ flex: 2 }} + styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} + /> +