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:
Danil Suhomlinov
2026-06-08 17:42:45 +03:00
commit 33800292aa
186 changed files with 30437 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
# Inventory (Vestimentație & Echipament) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Заменить свободные String-поля в `Benefit` на FK к новой модели `InventoryItem` со складским учётом, добавить страницу `/inventory` для CRUD и логику атомарного уменьшения остатков при выдаче.
**Architecture:** Новая Prisma модель + NestJS модуль (`inventory`) + React страница + перезапись `BenefitDrawer/BeneficiiTab` + `$transaction` для stock adjust в `BenefitService`.
**Tech Stack:** NestJS 10 + Prisma 5 + React 18 + Mantine v7 + TanStack Query v5
---
## Task 1: Prisma migration — InventoryItem model
**Files:**
- Modify: `apps/api/prisma/schema.prisma`
- Create: `apps/api/prisma/migrations/20260512000000_add_inventory/migration.sql`
- [ ] Step 1: Добавить enum + model в schema.prisma после `Benefit`:
```prisma
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")
}
```
- [ ] Step 2: Изменить `Benefit` — заменить `uniformaId String?` и т.д. на FK:
```prisma
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])
aparatTelefonId String?
aparatTelefon InventoryItem? @relation("BenefitAparatTel", fields: [aparatTelefonId], references: [id])
ticheteMasa Boolean @default(false)
valoareTichet Decimal? @db.Decimal(10, 2)
alimentatiePersonal Boolean @default(false)
abonamentTel Decimal? @db.Decimal(10, 2)
cardCompanie String?
automobilServiciu String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("benefits")
}
```
- [ ] Step 3: Создать SQL миграцию вручную (т.к. `migrate dev` не работает в non-interactive shell):
```sql
-- 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")
);
CREATE UNIQUE INDEX "inventory_items_sku_key" ON "inventory_items"("sku");
CREATE INDEX "inventory_items_type_active_idx" ON "inventory_items"("type", "active");
-- AlterTable: обнуляем старые свободные текстовые ID и добавляем FK
ALTER TABLE "benefits" ALTER COLUMN "uniformaId" DROP NOT NULL;
UPDATE "benefits" SET "uniformaId" = NULL WHERE "uniformaId" IS NOT NULL AND "uniformaId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "halatId" = NULL WHERE "halatId" IS NOT NULL AND "halatId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "ciupiciId" = NULL WHERE "ciupiciId" IS NOT NULL AND "ciupiciId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "vestaId" = NULL WHERE "vestaId" IS NOT NULL AND "vestaId" NOT IN (SELECT "id" FROM "inventory_items");
UPDATE "benefits" SET "aparatTelefonId" = NULL WHERE "aparatTelefonId" IS NOT NULL AND "aparatTelefonId" NOT IN (SELECT "id" FROM "inventory_items");
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_uniformaId_fkey" FOREIGN KEY ("uniformaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_halatId_fkey" FOREIGN KEY ("halatId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_ciupiciId_fkey" FOREIGN KEY ("ciupiciId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_vestaId_fkey" FOREIGN KEY ("vestaId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "benefits" ADD CONSTRAINT "benefits_aparatTelefonId_fkey" FOREIGN KEY ("aparatTelefonId") REFERENCES "inventory_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
```
- [ ] Step 4: Применить миграцию + регенерировать клиент:
```bash
pnpm --filter api exec prisma migrate deploy
pnpm --filter api exec prisma generate
```
- [ ] Step 5: Commit
```bash
git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260512000000_add_inventory/
git commit -m "feat(inventory): add InventoryItem model + FK from Benefit"
```
---
## Task 2: Inventory module (NestJS)
**Files:**
- Create: `apps/api/src/modules/inventory/inventory.module.ts`
- Create: `apps/api/src/modules/inventory/inventory.controller.ts`
- Create: `apps/api/src/modules/inventory/inventory.service.ts`
- Create: `apps/api/src/modules/inventory/dto/create-inventory.dto.ts`
- Create: `apps/api/src/modules/inventory/dto/update-inventory.dto.ts`
- Create: `apps/api/src/modules/inventory/dto/list-query.dto.ts`
- Create: `apps/api/src/modules/inventory/dto/adjust-stock.dto.ts`
- Modify: `apps/api/src/app.module.ts` — импортировать `InventoryModule`
- [ ] Step 1: DTO с `class-validator`:
```typescript
// create-inventory.dto.ts
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;
}
```
```typescript
// adjust-stock.dto.ts
export class AdjustStockDto {
@IsNumber() delta!: number; // +10, -5
@IsString() reason!: string;
}
```
```typescript
// list-query.dto.ts
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;
}
```
- [ ] Step 2: Service:
```typescript
// inventory.service.ts
@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 [total, items] = await this.prisma.$transaction([
this.prisma.inventoryItem.count({ where }),
this.prisma.inventoryItem.findMany({ where, orderBy: { name: 'asc' }, skip: ((q.page ?? 1) - 1) * limit, take: limit }),
]);
await this.audit.logRead({ userId, userRole: role, entity: 'InventoryItem', entityId: 'LIST' });
return { total, page: q.page ?? 1, limit, items };
}
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) {
// soft delete
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.inventoryItem.update({
where: { id },
data: { stockQty: { increment: dto.delta } },
});
if (item.stockQty < 0) throw new BadRequestException('Stoc negativ nu este permis');
await this.audit.logChange({
userId, userRole: role, action: 'UPDATE', entity: 'InventoryItem', entityId: id,
field: 'stockQty', newValue: String(item.stockQty), reason: dto.reason,
});
return item;
}
}
```
- [ ] Step 3: Controller:
```typescript
@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);
}
@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') 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') 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') id: string, @Body() dto: AdjustStockDto, @Request() req: AuthReq) {
return this.svc.adjustStock(id, dto, req.user.id, req.user.role);
}
}
```
- [ ] Step 4: Module + регистрация в `app.module.ts`. Commit.
---
## Task 3: Atomic stock adjust в BenefitService
**Files:**
- Modify: `apps/api/src/modules/employees/sub-resources/benefit.service.ts` (или где сейчас upsert логика)
- [ ] Step 1: Переписать `upsert(employeeId, dto)`:
```typescript
async upsert(employeeId: string, dto: UpsertBenefitDto, userId: string, role: string) {
const old = await this.prisma.benefit.findUnique({ where: { employeeId } });
const fields: Array<keyof Pick<UpsertBenefitDto, 'uniformaId'|'halatId'|'ciupiciId'|'vestaId'|'aparatTelefonId'>> = [
'uniformaId', 'halatId', 'ciupiciId', 'vestaId', 'aparatTelefonId',
];
return this.prisma.$transaction(async (tx) => {
for (const f of fields) {
const oldId = old?.[f] ?? null;
const newId = dto[f] ?? 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}`);
}
}
const benefit = await tx.benefit.upsert({
where: { employeeId },
create: { employeeId, ...dto },
update: dto,
});
return benefit;
}).then(async (b) => {
await this.audit.logChange({ userId, userRole: role, action: old ? 'UPDATE' : 'CREATE', entity: 'Benefit', entityId: b.id });
return b;
});
}
```
- [ ] Step 2: Тест mental: dryRun со старым null+новым null — пропустить; oldA→newB — A++, B--.
- [ ] Step 3: Commit.
---
## Task 4: Seed data + frontend types
**Files:**
- Modify: `apps/api/prisma/seed.ts` — добавить ~15 предметов
- Modify: `apps/web/src/api/types.ts``InventoryItem`, `InventoryItemType`, `Benefit` (с relation полями)
- [ ] Step 1: Seed (4 uniforme + 4 halate + 3 ciupici + 2 veste + 2 aparate, у каждого `stockQty: 50`):
```typescript
const inventory = [
{ sku: 'UN-CHIR-M-AL', name: 'Uniformă chirurgie M albastru', type: 'uniforma', size: 'M', color: 'albastru', stockQty: 50 },
{ sku: 'UN-CHIR-L-AL', name: 'Uniformă chirurgie L albastru', type: 'uniforma', size: 'L', color: 'albastru', stockQty: 50 },
// ... ещё 13
];
for (const i of inventory) {
await prisma.inventoryItem.upsert({ where: { sku: i.sku }, create: i, update: {} });
}
```
- [ ] Step 2: Frontend types:
```typescript
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; // Decimal serialized
stockQty: number;
active: boolean;
}
export interface PaginatedInventory { total: number; page: number; limit: number; items: InventoryItem[]; }
export interface Benefit {
// ... existing
uniformaId: string | null;
uniforma: InventoryItem | null;
halatId: string | null;
halat: InventoryItem | null;
ciupiciId: string | null;
ciupici: InventoryItem | null;
vestaId: string | null;
vesta: InventoryItem | null;
aparatTelefonId: string | null;
aparatTelefon: InventoryItem | null;
// ...
}
```
- [ ] Step 3: Run seed: `pnpm --filter api prisma:seed`. Commit.
---
## Task 5: InventoryPage + Drawer
**Files:**
- Create: `apps/web/src/pages/inventory/InventoryPage.tsx`
- Create: `apps/web/src/pages/inventory/InventoryDrawer.tsx`
- Create: `apps/web/src/pages/inventory/StockAdjustModal.tsx`
- [ ] Step 1: `InventoryPage` — таблица + filter bar (search, type select, active switch) + кнопка «Adaugă articol». Stoc=0 → red row, <5 → amber.
- [ ] Step 2: `InventoryDrawer` — react-hook-form + Zod, поля SKU/Denumire/Tip/Mărime/Culoare/Preț/Stoc inițial/Active. POST/PATCH через axios.
- [ ] Step 3: `StockAdjustModal``<NumberInput delta>` + `<Textarea reason>`, POST `/inventory/:id/adjust-stock`.
- [ ] Step 4: Commit.
---
## Task 6: Wire в App.tsx + nav
**Files:**
- Modify: `apps/web/src/App.tsx`
- [ ] Step 1: Импорт `IconBox` + `InventoryPage`.
- [ ] Step 2: Добавить в `NAV_ITEMS`:
```ts
{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox size={20} stroke={1.6} color="#008286" />, roles: ['hr_admin', 'hr_specialist'] }
```
- [ ] Step 3: Route: `<Route path="/inventory" element={<InventoryPage />} />`
- [ ] Step 4: i18n key `nav.inventory: "Inventar"`.
- [ ] Step 5: Commit.
---
## Task 7: Перезаписать BenefitDrawer + BeneficiiTab
**Files:**
- Modify: `apps/web/src/pages/employees/drawers/BenefitDrawer.tsx`
- Modify: `apps/web/src/pages/employees/tabs/BeneficiiTab.tsx`
- [ ] Step 1: В `BenefitDrawer` заменить 5 `<TextInput>` на `<Select searchable>`:
```typescript
const { data: uniforme } = useQuery({
queryKey: ['inventory', 'uniforma'],
queryFn: () => apiClient.get('/inventory', { params: { type: 'uniforma', active: true, limit: 200 } }).then(r => r.data),
staleTime: 60_000,
});
<Controller name="uniformaId" control={control} render={({ field }) => (
<Select
label="Uniformă"
data={(uniforme?.items ?? []).map((it: InventoryItem) => ({
value: it.id,
label: `${it.sku} — ${it.name}${it.size ? ` (${it.size})` : ''} · stoc: ${it.stockQty}`,
disabled: it.stockQty === 0 && it.id !== field.value,
}))}
searchable
clearable
{...field}
/>
)} />
```
Повторить для halat / ciupici / vesta / aparatTelefon.
- [ ] Step 2: В `BeneficiiTab` заменить `BField label="Uniformă (ID)" value={benefit.uniformaId}` на:
```tsx
<BField label="Uniformă" value={benefit.uniforma ? `${benefit.uniforma.sku} — ${benefit.uniforma.name}` : null} />
```
- [ ] Step 3: На бэке в `benefit.service.ts` добавить `include: { uniforma: true, halat: true, ciupici: true, vesta: true, aparatTelefon: true }` во все find-методы.
- [ ] Step 4: Commit.
---
## Self-Review Checklist (после написания)
- [ ] Все ID полей в Benefit стали FK
- [ ] Atomic stock в `$transaction`, не race condition
- [ ] Soft delete если `InventoryItem` используется
- [ ] Seed создаёт ~15 предметов
- [ ] Drawer disable=true при stockQty=0 (кроме уже выбранного)
- [ ] Audit log для CRUD + adjust-stock
- [ ] Все эндпоинты под `/api/v1/`
@@ -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 | LuniVineri, 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