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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
# Design: Contracts UI, Seed Data, Anexa Template Editor
|
||||
|
||||
**Date:** 2026-05-08
|
||||
**Scope:** Three independent features added on top of the existing HRM Medpark monorepo (NestJS API + React/Vite/Mantine web).
|
||||
|
||||
---
|
||||
|
||||
## 1. Contracts UI
|
||||
|
||||
### Goal
|
||||
A standalone **«Contracte»** section in the main navigation showing all employment contracts across all employees, with expiry tracking for determinata contracts.
|
||||
|
||||
### What already exists
|
||||
- `contracts.controller.ts` and `contracts.service.ts` in `apps/api/src/modules/employees/sub-resources/`
|
||||
- `ContractDrawer.tsx` and `ContracteTab.tsx` in the web app (employee-scoped)
|
||||
- Prisma model `EmploymentContract` with all fields from ТЗ §3.2
|
||||
|
||||
### New frontend: `/contracts` page
|
||||
|
||||
**List view (`ContractsPage.tsx`):**
|
||||
- Table columns: Nr. CIM, Angajat (link to employee), Funcția, Secția, Tip perioadă, Data angajării, Data terminării, Salarizare, Status
|
||||
- Status badge: `activ` (teal) / `expirat` (red) / `expiră în curând` (amber — ≤30 days for determinata)
|
||||
- Filters (top bar): Departament (select), Tip perioadă (determinata / nedeterminata / replasare_temporara), Status (activ / expirat / în_curând), search by employee name
|
||||
- Click any row → opens `ContractFormDrawer` (reuses existing `ContractDrawer.tsx` logic, extended)
|
||||
- "Adaugă contract" button → same drawer with empty form + employee search field
|
||||
|
||||
**Contract form drawer (extended):**
|
||||
- Fields: `nr_cim`, `categorie` (principal/secundar), `data_semnarii`, `data_angajarii`, `data_demisiei`
|
||||
- `perioada` radio: determinata / nedeterminata / replasare_temporara
|
||||
- If `determinata` → show `data_terminarii` date field
|
||||
- `functia_organigrama`, `cod_functie`, `functia_clasificator` (CORM)
|
||||
- `tip_cim` (de_baza / cumul), `sectia_id` (department select), `regim_munca`
|
||||
- `tip_salarizare` radio: fix / pe_ore / in_acord
|
||||
- **fix** → `suma`, `valuta`, `grafic_id` (WorkSchedule select), `ore_saptaminal`, `zile_concediu`
|
||||
- **pe_ore** → `tarif_ora`, `zile_concediu`
|
||||
- **in_acord** → `grafic_id`, `ore_saptaminal`, `zile_concediu`, `suma_fix`, `valuta`
|
||||
- `clauza_aditionala` (optional): `suma` + `valuta`
|
||||
- `categorii_servicii` table: up to 6 rows, each: categorie_id, tip_remunerare (tarif/procent), suma_net or procent
|
||||
|
||||
**New API endpoint needed:**
|
||||
- `GET /api/v1/contracts` — paginated list of ALL contracts across employees, with filters: `departmentId`, `perioada`, `status` (active/expired/expiring), `search`
|
||||
- Reuses existing per-employee endpoints for create/update/delete
|
||||
|
||||
**Business rule display:**
|
||||
- If employee has multiple contracts, show vacation days as MAX across contracts (read-only badge on drawer header)
|
||||
|
||||
### Navigation
|
||||
Add `{ labelKey: 'nav.contracts', path: '/contracts', icon: IconFileDescription }` to `NAV_ITEMS` in `App.tsx`. Roles: `hr_admin`, `hr_specialist`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Seed Data
|
||||
|
||||
### Goal
|
||||
Populate reference tables that are currently empty, blocking form dropdowns.
|
||||
|
||||
### WorkSchedule presets (4 records)
|
||||
| name | description | hoursPerDay | daysOn | daysOff |
|
||||
|------|-------------|-------------|--------|---------|
|
||||
| Standard 5/2 | Luni–Vineri, 8h | 8 | 5 | 2 |
|
||||
| Tură 7/7 | 12h, zi/noapte alternant | 12 | 7 | 7 |
|
||||
| Ambulatoriu 5/2 | 12h zi | 12 | 5 | 2 |
|
||||
| Gardă 24/72 | 24h gardă, 72h repaus | 24 | 1 | 3 |
|
||||
|
||||
### DisabilityGrade (4 records)
|
||||
| code | name |
|
||||
|------|------|
|
||||
| NONE | Fără dizabilitate |
|
||||
| GR_I | Dizabilitate severă (gr. I) |
|
||||
| GR_II | Dizabilitate accentuată (gr. II) |
|
||||
| GR_III | Dizabilitate medie (gr. III) |
|
||||
|
||||
### TaxExemption — IRS Moldova (4 records)
|
||||
| code | name | annualAmount |
|
||||
|------|------|-------------|
|
||||
| SP | Scutire personală | 27000 |
|
||||
| SPM | Scutire personală majoră | 34500 |
|
||||
| SS | Scutire pentru soț/soție | 27000 |
|
||||
| SI | Scutire pentru persoane întreținute (copil) | 9000 |
|
||||
|
||||
### Departments tree (placeholder structure)
|
||||
```
|
||||
Medpark International Hospital (root)
|
||||
├── Nursing
|
||||
│ ├── Nursing Anestezie
|
||||
│ ├── Nursing Terapie
|
||||
│ ├── Nursing ATI
|
||||
│ └── Nursing Chirurgie
|
||||
├── Medicină
|
||||
│ ├── Chirurgie
|
||||
│ ├── Terapie
|
||||
│ └── Anestezie-Reanimare
|
||||
└── Administrativ
|
||||
├── Resurse Umane
|
||||
├── Calitate
|
||||
└── Financiar-Contabilitate
|
||||
```
|
||||
These are placeholder names — HR admin will edit the tree in the Departments UI.
|
||||
|
||||
### Delivery
|
||||
All seed data added to `apps/api/prisma/seed.ts`, idempotent (upsert by code/name). Run via `pnpm db:seed`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Anexa Template Editor
|
||||
|
||||
### Goal
|
||||
An `hr_admin`-only section where staff can edit the text and structure of Anexa 3, 4, 4B, and 6 DOCX templates directly in the browser (rich-text, Word-like), with a live preview populated by test employee data.
|
||||
|
||||
### Data model — new Prisma models
|
||||
|
||||
```prisma
|
||||
model AnexaTemplate {
|
||||
id String @id @default(uuid())
|
||||
type AnexaType @unique // ANEXA_3 | ANEXA_4 | ANEXA_4B | ANEXA_6
|
||||
name String
|
||||
contentJson Json // TipTap JSON document
|
||||
updatedById String
|
||||
updatedAt DateTime @updatedAt
|
||||
versions AnexaTemplateVersion[]
|
||||
}
|
||||
|
||||
model AnexaTemplateVersion {
|
||||
id String @id @default(uuid())
|
||||
templateId String
|
||||
template AnexaTemplate @relation(fields: [templateId], references: [id])
|
||||
contentJson Json
|
||||
savedById String
|
||||
savedAt DateTime @default(now())
|
||||
label String? // optional human label e.g. "Versiunea mai 2026"
|
||||
}
|
||||
|
||||
enum AnexaType {
|
||||
ANEXA_3
|
||||
ANEXA_4
|
||||
ANEXA_4B
|
||||
ANEXA_6
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints (new NestJS module: `AnexaTemplatesModule`)
|
||||
|
||||
| Method | Path | Role | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/v1/admin/anexa-templates` | hr_admin | List all 4 templates (metadata only) |
|
||||
| GET | `/api/v1/admin/anexa-templates/:type` | hr_admin | Full template with contentJson |
|
||||
| PUT | `/api/v1/admin/anexa-templates/:type` | hr_admin | Save template + auto-create version |
|
||||
| GET | `/api/v1/admin/anexa-templates/:type/versions` | hr_admin | List version history |
|
||||
| POST | `/api/v1/admin/anexa-templates/:type/restore/:versionId` | hr_admin | Restore a version |
|
||||
| GET | `/api/v1/admin/anexa-templates/preview-employee` | hr_admin | Get a test employee for preview |
|
||||
|
||||
### Frontend pages
|
||||
|
||||
**`/admin/templates` — template list:**
|
||||
- 4 cards, one per Anexa, showing name + last updated + updatedBy
|
||||
- Click → `/admin/templates/:type`
|
||||
|
||||
**`/admin/templates/:type` — two-panel editor:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [Teal topbar] Anexa 3 — Fișa de solicitare [↺ Reset] [💾 Salvează] │
|
||||
├────────────────────────────────┬────────────────────────────┤
|
||||
│ [Formatting toolbar] │ │
|
||||
│ B I U | ≡ ⊞Tabel | +Angajat +Companie +Data │ │
|
||||
├────────────────────────────────┤ │
|
||||
│ │ 👁 Preview │
|
||||
│ LEFT: TipTap editor │ [Schimbă angajatul test] │
|
||||
│ with variable chips │ │
|
||||
│ │ RIGHT: HTML render of │
|
||||
│ teal chip = employee data │ same doc with real values │
|
||||
│ blue chip = document data │ substituted in │
|
||||
│ orange chip = loop row vars │ │
|
||||
└────────────────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
**Variable chip system:**
|
||||
- Custom TipTap Node extension `VariableChip`: non-editable inline atom, rendered as colored pill
|
||||
- Stored in JSON as `{ type: "variableChip", attrs: { key: "employee.lastName", label: "Numele" } }`
|
||||
- Color coding: `employee.*` → teal, `document.*` → indigo, `row.*` → orange (loop vars inside table rows)
|
||||
- Sidebar panel (collapsible): grouped list of all available variables with "Insert" button
|
||||
|
||||
**Variable namespace (initial set):**
|
||||
|
||||
| Key | Label | Context |
|
||||
|-----|-------|---------|
|
||||
| `company.name` | Denumirea unității | document |
|
||||
| `company.idno` | IDNO | document |
|
||||
| `company.address` | Adresa | document |
|
||||
| `document.date` | Data solicitării | document |
|
||||
| `document.number` | Numărul documentului | document |
|
||||
| `employee.lastName` | Numele | employee |
|
||||
| `employee.firstName` | Prenumele | employee |
|
||||
| `employee.idnp` | IDNP | employee |
|
||||
| `employee.birthYear` | Anul nașterii | employee |
|
||||
| `employee.occupation` | Ocupația (CORM) | employee |
|
||||
| `employee.department` | Secția | employee |
|
||||
| `row.index` | Nr. crt. | loop row |
|
||||
| `row.seatNumber` | Nr. loc de muncă | loop row |
|
||||
| `row.employeeName` | Numele angajatului | loop row |
|
||||
| `row.riskFactors` | Factorii de risc | loop row |
|
||||
| `radiation.externalMsv` | Doza externă (mSv) | radiation |
|
||||
| `radiation.internalMsv` | Doza internă (mSv) | radiation |
|
||||
|
||||
**Preview panel:**
|
||||
- A second read-only TipTap instance with a `VariableChipPreview` node extension — identical to `VariableChip` but renders the resolved value (e.g. "Ionescu") instead of the chip pill
|
||||
- The editor document is synced from the left panel's JSON on every change (`editor.on('update', syncPreview)`)
|
||||
- Test employee data is fetched once via `GET /admin/anexa-templates/preview-employee` and stored in React state; variable keys are resolved against this object at render time inside the node extension
|
||||
- "Schimbă angajatul test" → opens employee search modal, replaces preview data, re-renders
|
||||
- Preview updates in real time as user types (no debounce needed — TipTap JSON sync is cheap)
|
||||
|
||||
**Version history:**
|
||||
- "Versiuni" button in topbar → right panel slides to show version list
|
||||
- Each version: timestamp, saved by, optional label, "Restaurează" button
|
||||
|
||||
### DOCX generation integration
|
||||
|
||||
The existing `document-generator.service.ts` currently builds DOCX programmatically. After this feature, it will:
|
||||
1. Load `AnexaTemplate.contentJson` for the requested type
|
||||
2. Walk TipTap JSON AST (recursive)
|
||||
3. Replace `variableChip` nodes with resolved string values
|
||||
4. Convert nodes → `docx` library objects:
|
||||
- `paragraph` → `new Paragraph()`
|
||||
- `table` → `new Table()` with `TableRow`/`TableCell`
|
||||
- `bold`, `italic`, `underline` marks → `TextRun` options
|
||||
- `heading` → `new Paragraph({ heading: HeadingLevel.HEADING_X })`
|
||||
5. Pass to `Packer.toBuffer()` → upload to MinIO
|
||||
|
||||
The converter lives in `apps/api/src/modules/medical/services/tiptap-to-docx.ts`.
|
||||
|
||||
### Seed templates
|
||||
On first run, `seed.ts` seeds each `AnexaTemplate` with a minimal valid TipTap JSON that matches the current programmatic output of `document-generator.service.ts`. This ensures zero regression on existing DOCX generation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Out of scope for this iteration
|
||||
|
||||
- PDF export (DOCX only, printed and signed by hand per ТЗ §9)
|
||||
- Multi-language templates (Romanian only per ТЗ)
|
||||
- Contract DOCX generation (not in ТЗ §3.2 requirements)
|
||||
- Loop block editor (table rows with `row.*` variables are added manually as table rows; the `row.*` chips simply mark which cells repeat — the actual loop logic stays in `tiptap-to-docx.ts`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation order
|
||||
|
||||
1. **Seed data** — no dependencies, unblocks dropdowns immediately
|
||||
2. **Contracts UI** — GET /contracts endpoint + ContractsPage + extended drawer
|
||||
3. **Anexa editor** — Prisma migration → API module → TipTap editor → tiptap-to-docx converter → seed templates
|
||||
@@ -0,0 +1,152 @@
|
||||
# Design: Inventory (Vestimentație & Echipament)
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Scope:** Полноценный модуль склада инвентаря для HR Medpark — заменяет свободные текстовые ID в `Benefit` на FK к таблице `InventoryItem`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
В разделе «Beneficii» сотрудника поля «Uniformă / Halat / Ciupici / Vestă / Aparat telefon» сейчас хранятся как свободный текст (`uniformaId String?`). По ТЗ (Rubrici necesare, B121-B131 — «info de la depozit») это должна быть выдача со склада с учётом остатков.
|
||||
|
||||
Модуль `Inventory`:
|
||||
- Реестр товаров склада (SKU, наименование, тип, размер, цвет, цена, остаток).
|
||||
- CRUD для `hr_admin`, read для `hr_specialist`.
|
||||
- В `BenefitDrawer` — `<Select>` с поиском по складу вместо `<TextInput>`.
|
||||
- При выдаче `Benefit` уменьшается `stockQty` (атомарно в транзакции).
|
||||
- При снятии выдачи — возвращается на склад.
|
||||
|
||||
## 2. Out of scope (для первой итерации)
|
||||
|
||||
- Учёт серий/партий с истекающим сроком годности
|
||||
- Списание (write-off) с причиной — пока только повышение/понижение `stockQty` через CRUD
|
||||
- Резервирование за сотрудником без выдачи
|
||||
- Импорт остатков из Excel
|
||||
- История движений (audit идёт через общий `AuditLog`, специальной таблицы движений нет)
|
||||
|
||||
## 3. Data model
|
||||
|
||||
```prisma
|
||||
enum InventoryItemType {
|
||||
uniforma
|
||||
halat
|
||||
ciupici
|
||||
vesta
|
||||
aparat_telefon
|
||||
alte
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(uuid())
|
||||
sku String @unique // артикул
|
||||
name String // "Uniformă chirurgie M albastru"
|
||||
type InventoryItemType
|
||||
size String? // "M", "42"
|
||||
color String?
|
||||
pricePerUnit Decimal? @db.Decimal(10, 2)
|
||||
stockQty Int @default(0)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Обратные связи на Benefit (по типам)
|
||||
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")
|
||||
}
|
||||
|
||||
// Изменения в Benefit:
|
||||
// - uniformaId String? → FK к InventoryItem (relation "BenefitUniforma")
|
||||
// - halatId, ciupiciId, vestaId, aparatTelefonId — аналогично
|
||||
```
|
||||
|
||||
### Миграция данных
|
||||
|
||||
Существующие свободные String значения в `Benefit.uniformaId/...` теряют связь — поле обнуляется (NULL). Сообщить HR-у в release notes, что прошлые «текстовые» значения нужно перевыбрать из Inventory.
|
||||
|
||||
## 4. API endpoints
|
||||
|
||||
Модуль `apps/api/src/modules/inventory/`:
|
||||
|
||||
| Method | Path | Role | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/api/v1/inventory` | hr_admin, hr_specialist | List + filter (type, search, активные) + pagination |
|
||||
| GET | `/api/v1/inventory/:id` | hr_admin, hr_specialist | Один товар |
|
||||
| POST | `/api/v1/inventory` | hr_admin | Создать |
|
||||
| PATCH | `/api/v1/inventory/:id` | hr_admin | Обновить |
|
||||
| DELETE | `/api/v1/inventory/:id` | hr_admin | Soft delete (`active = false` если есть выдачи, hard delete если их нет) |
|
||||
| POST | `/api/v1/inventory/:id/adjust-stock` | hr_admin | `{ delta: number, reason: string }` — приход / списание |
|
||||
|
||||
Read-методы — `audit.logRead({ entity: 'InventoryItem' })`. Write — `logChange`.
|
||||
|
||||
## 5. Frontend
|
||||
|
||||
### `/inventory` — список
|
||||
- Таблица: SKU / Denumire / Tip / Mărime / Culoare / Stoc / Preț / Acțiuni
|
||||
- Filter bar: `<Select tip>`, `<Switch active only>`, search (SKU+name)
|
||||
- Кнопка «Adaugă articol» → drawer
|
||||
- Highlight: `stockQty === 0` → red row, `stockQty < 5` → amber
|
||||
- Click row → drawer для редактирования
|
||||
|
||||
### Drawer (CRUD)
|
||||
- Поля: SKU, Denumire, Tip (select 6), Mărime, Culoare, Preț unitar (MDL), Stoc inițial, Active toggle
|
||||
- Валидация: SKU unique (handled на бэке), name required
|
||||
|
||||
### `/inventory/:id` — Stock adjust modal
|
||||
- Открывается из строки таблицы или drawer
|
||||
- Inputs: delta (число с +/-), reason (textarea)
|
||||
- Audit log пишется обязательно
|
||||
|
||||
### Изменения в `BenefitDrawer.tsx`
|
||||
- Заменить 5 `<TextInput>` (uniformaId, halatId, ciupiciId, vestaId, aparatTelefonId) на `<Select searchable>` с фильтром по `type`:
|
||||
```ts
|
||||
const { data: uniforme } = useQuery(['inventory', 'uniforma'],
|
||||
() => apiClient.get('/inventory', { params: { type: 'uniforma', active: true } }));
|
||||
```
|
||||
- Подпись опции: `${item.sku} — ${item.name} (${item.size}, stoc: ${item.stockQty})`
|
||||
- Disable опции с `stockQty === 0` (за исключением уже выбранной у текущего сотрудника)
|
||||
|
||||
### Изменения в `BeneficiiTab.tsx`
|
||||
- Резолвить `benefit.uniforma?.name` (через `include` на бэке) вместо ID
|
||||
- Показ: «Uniformă: Uniformă chirurgie M albastru (SKU-001)»
|
||||
|
||||
### Nav
|
||||
- В `App.tsx` → `NAV_ITEMS`: `{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox />, roles: ['hr_admin', 'hr_specialist'] }`
|
||||
|
||||
## 6. Бизнес-логика выдачи (atomic stock adjustment)
|
||||
|
||||
В `BenefitService.upsert(employeeId, dto)`:
|
||||
1. Загрузить текущий `Benefit` (если есть) → diff `oldUniformaId vs newUniformaId`
|
||||
2. В транзакции `prisma.$transaction`:
|
||||
- Если `oldUniformaId` сменился: `stockQty++` для старого
|
||||
- Если `newUniformaId` указан: проверить `stockQty > 0`, иначе `BadRequest('Stoc epuizat')`. Затем `stockQty--`
|
||||
3. Upsert Benefit
|
||||
4. Audit log
|
||||
|
||||
Аналогично для всех 5 полей.
|
||||
|
||||
## 7. Seed data
|
||||
|
||||
В `apps/api/prisma/seed.ts`:
|
||||
- 4 модели Uniforme (M/L/XL chirurgie, M/L/XL ATI)
|
||||
- 4 модели Halate
|
||||
- 3 размера Ciupici (38-42, 43-46)
|
||||
- 2 модели Veste (S, M)
|
||||
- 2 модели Aparate telefon (Samsung A15, iPhone SE)
|
||||
|
||||
Каждая со `stockQty: 50`.
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
1. **Prisma migration** — модель `InventoryItem`, FK в `Benefit`, seed
|
||||
2. **API module** — controller + service + DTOs
|
||||
3. **Frontend types** + `/inventory` page + drawer
|
||||
4. **BenefitDrawer rewrite** — селекты вместо TextInput
|
||||
5. **BeneficiiTab** — показ резолвленных имён
|
||||
6. **Atomic stock logic** в BenefitService
|
||||
7. **Stock adjust** modal + endpoint
|
||||
Reference in New Issue
Block a user