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
+464
View File
@@ -0,0 +1,464 @@
# Control Medical — Сценарии UI-тестирования
> Проверяем только то, что видит и делает пользователь в браузере.
> Открыть: `http://localhost:5173`
> Логин: страница входа → выбрать пользователя и роль → «Autentifică-te»
---
## Роли для тестирования
| Роль | Что можно делать |
|------|-----------------|
| **HR Admin** | Всё |
| **HR Specialist** | Создавать профили и checkup, удалять документы |
| **Medic Familie** | Видеть inbox, записывать вердикт |
| **Manager** | Только просматривать |
| **Employee** | Только просматривать своё |
---
## 1. Carduri de risc
### TC-01 — Создание карты риска
**Роль:** HR Admin
**Шаги:**
1. Меню слева → «Carduri de risc»
2. Нажать «+ Adaugă card de risc»
3. Ввести название, добавить хотя бы 1 фактор риска
4. Нажать «Salvează»
**Ожидаем:** Карта появляется в таблице, виден бейдж с количеством факторов
---
### TC-02 — Редактирование карты
**Роль:** HR Admin
**Шаги:**
1. В таблице карт нажать иконку карандаша
2. Изменить название или добавить новый фактор
3. Сохранить
**Ожидаем:** В таблице отображается обновлённое название/количество факторов
---
### TC-03 — Удаление карты
**Роль:** HR Admin
**Шаги:**
1. Создать тестовую карту (не привязанную к сотрудникам)
2. Нажать иконку корзины
3. Подтвердить удаление
**Ожидаем:** Карта исчезает из таблицы
---
### TC-04 — HR Specialist не видит кнопки удаления и создания
**Роль:** HR Specialist
**Шаги:**
1. Открыть «Carduri de risc»
**Ожидаем:** Кнопка «+ Adaugă» и иконки удаления отсутствуют
---
### TC-05 — Карта со всеми категориями факторов
**Роль:** HR Admin
**Шаги:**
1. Создать карту, добавив факторы в каждую из 5 категорий: chimici, fizici, biologici, ergonomici, psihosociali
2. Сохранить и открыть карту
**Ожидаем:** Все 5 категорий с факторами отображаются в деталях карты
---
## 2. Profil medical сотрудника
### TC-06 — Назначение медицинского профиля
**Роль:** HR Admin
**Шаги:**
1. Меню → «Angajați» → открыть любого сотрудника
2. Вкладка «Medical» → кнопка «Editează»
3. Выбрать карту риска, ввести CORM-код и специальность
4. Сохранить
**Ожидаем:** В профиле отображается карта риска как кликабельная ссылка, CORM-специальность заполнена, «ULTIMUL CONTROL» пуст
---
### TC-07 — Включение флага «Expus radiații»
**Роль:** HR Admin
**Шаги:**
1. Открыть медицинский профиль сотрудника → «Editează»
2. Включить тогл «Expus radiații ionizante»
3. Ввести дозу externă и internă
4. Сохранить
**Ожидаем:** Поле «EXPUS RADIAȚII: Da» отображается в профиле; значение dozaTotal = externă + internă
---
### TC-08 — Смена карты риска
**Роль:** HR Admin
**Шаги:**
1. У сотрудника уже есть профиль с картой риска
2. «Editează» → выбрать другую карту → сохранить
**Ожидаем:** Новая карта отображается в профиле; история checkup не потерялась
---
### TC-09 — Просмотр профиля как Employee
**Роль:** Employee
**Шаги:**
1. Войти как employee → открыть свой профиль → вкладка «Medical»
**Ожидаем:** Данные видны, но кнопок «Editează» и удаления нет
---
## 3. Страница Control medical
### TC-10 — Статистические карточки отображаются корректно
**Роль:** HR Admin
**Шаги:**
1. Убедиться, что есть хотя бы 1 сотрудник с медицинским профилем
2. Открыть «Control medical»
**Ожидаем:** Карточки «TOTAL ANGAJAȚI», «CONTROL EXPIRAT», «EXPIRĂ ÎN 30 ZILE», «EXPUȘI RADIAȚII» показывают числа (не пустые и не нулевые если данные есть)
---
### TC-11 — Сотрудник без профиля не виден
**Роль:** HR Admin
**Шаги:**
1. Создать нового сотрудника, не добавляя ему медицинский профиль
2. Открыть «Control medical»
**Ожидаем:** Новый сотрудник в таблице отсутствует
---
### TC-12 — Статус NICIODATĂ у нового сотрудника с профилем
**Роль:** HR Admin
**Шаги:**
1. Назначить медицинский профиль новому сотруднику
2. Открыть «Control medical»
**Ожидаем:** Сотрудник виден в таблице, красный бейдж «NICIODATĂ», колонка «ULTIMUL CONTROL» показывает «—»
---
### TC-13 — Выбор строки кликом
**Роль:** HR Admin
**Шаги:**
1. На странице «Control medical» кликнуть на строку сотрудника
**Ожидаем:** Строка подсвечивается зелёным, чекбокс заполнен, счётчик кнопки «Generează documente» стал (1)
---
### TC-14 — Снятие выбора повторным кликом
**Роль:** HR Admin
**Шаги:**
1. Кликнуть строку (выбрать) → кликнуть снова (снять выбор)
**Ожидаем:** Подсветка пропала, счётчик вернулся к (0), кнопка стала неактивной
---
### TC-15 — Выбор всех через хедер-чекбокс
**Роль:** HR Admin
**Шаги:**
1. Кликнуть чекбокс в шапке таблицы
**Ожидаем:** Все строки выделены, счётчик = количество строк в таблице
---
### TC-16 — Фильтр по карте риска
**Роль:** HR Admin
**Шаги:**
1. В дропдауне «Toate» выбрать конкретную карту риска
**Ожидаем:** В таблице остаются только сотрудники с этой картой; счётчик кнопки сбрасывается в (0)
---
### TC-17 — Кнопка заблокирована при 0 выбранных
**Роль:** HR Admin
**Шаги:**
1. Открыть «Control medical», ничего не выбирать
**Ожидаем:** Кнопка «Generează documente (0)» серая и некликабельная
---
## 4. Генерация документов (Bulk)
### TC-18 — Открытие модала генерации
**Роль:** HR Admin
**Шаги:**
1. Выбрать сотрудника → нажать «Generează documente (1)»
**Ожидаем:** Открывается модал «Inițiere control medical» с:
- Текстом «1 angajați selectați»
- Дропдауном «Tipul controlului medical» (по умолчанию «Periodic»)
- Полем «Data planificată» (заполнено сегодняшней датой)
- Блоком с описанием документов, которые будут созданы
- Кнопками «Anulează» и «Generează documente»
---
### TC-19 — Генерация без флага радиации
**Роль:** HR Admin (сотрудник без `expusRadiatiiIonizante`)
**Шаги:**
1. Выбрать сотрудника → «Generează documente» → тип «Periodic» → дата сегодня → «Generează documente»
2. Подождать ~2 секунды
**Ожидаем:** Модал результата с:
- Заголовком «✓ Documente generate cu succes»
- 3 документа: ANEXA_3_FISA_SOLICITARE.DOCX, ANEXA_4_FISA_EVALUARE.DOCX, ANEXA_6_[ИМЯ].DOCX
- Anexa_4B **отсутствует**
- Toast «Documente generate» в углу экрана
---
### TC-20 — Генерация с флагом радиации
**Роль:** HR Admin (сотрудник с включённым `expusRadiatiiIonizante`)
**Шаги:**
1. Выбрать сотрудника с флагом радиации → «Generează documente» → подтвердить
**Ожидаем:** В результате **4 документа**: Anexa_3, Anexa_4, **Anexa_4B**, Anexa_6
---
### TC-21 — Тип checkup меняется в модале
**Роль:** HR Admin
**Шаги:**
1. Открыть модал генерации → сменить тип с «Periodic» на «La angajare»
**Ожидаем:** Выбор сохраняется, генерация проходит с типом «La angajare»
---
### TC-22 — Отмена генерации
**Роль:** HR Admin
**Шаги:**
1. Выбрать сотрудника → открыть модал → нажать «Anulează»
**Ожидаем:** Модал закрывается, ничего не создаётся, сотрудник остаётся выбранным
---
### TC-23 — Checkup появляется в истории сотрудника
**Роль:** HR Admin
**Шаги:**
1. Сгенерировать документы → закрыть результат
2. Меню «Angajați» → открыть сотрудника → вкладка «Medical»
**Ожидаем:** В «Istoricul controlului medical» новая строка: тип «PERIODIC», дата планирования, вердикт «IN AȘTEPTARE», 3 кликабельных документа
---
### TC-24 — Счётчик радиации на странице
**Роль:** HR Admin
**Шаги:**
1. Включить `expusRadiatiiIonizante` у одного сотрудника → открыть «Control medical»
**Ожидаем:** Карточка «EXPUȘI RADIAȚII» показывает 1 (или увеличивается на 1)
---
## 5. Inbox medic de familie
### TC-25 — Pending checkup виден в inbox
**Роль:** Medic Familie
**Шаги:**
1. Войти как Medic Familie
2. Меню → «Inbox medic»
**Ожидаем:** Таблица с pending checkup (verdict = null): колонки IDNP, ANGAJAT, TIP CONTROL, DATA PLANIFICATĂ, CARD DE RISC, кнопка «Completează»
---
### TC-26 — Метка «Întârziat» на просроченных
**Роль:** Medic Familie
**Шаги:**
1. Создать checkup с датой планирования в прошлом (или просто проверить текущие)
2. Открыть «Inbox medic»
**Ожидаем:** Рядом с датой оранжевая метка «Întârziat»
---
### TC-27 — Запись вердикта «Apt»
**Роль:** Medic Familie
**Шаги:**
1. Нажать «Completează» у checkup
2. Выбрать вердикт «Apt», дата заполнена автоматически
3. Нажать «Înregistrează verdict»
**Ожидаем:**
- Toast «Verdictul a fost înregistrat»
- Строка исчезает из inbox
- Список сокращается на 1
---
### TC-28 — Все варианты вердикта доступны
**Роль:** Medic Familie
**Шаги:**
1. Нажать «Completează» → открыть дропдаун «Verdict»
**Ожидаем:** 5 вариантов: Apt, Apt (perioadă adaptare), Apt condiționat, Inapt temporar, Inapt
---
### TC-29 — Нельзя отправить без вердикта
**Роль:** Medic Familie
**Шаги:**
1. Открыть «Completează» → НЕ выбирать вердикт → нажать «Înregistrează verdict»
**Ожидаем:** Кнопка не отправляет форму или отображается ошибка валидации (поле обязательное)
---
### TC-30 — Рекомендации (необязательное поле)
**Роль:** Medic Familie
**Шаги:**
1. «Completează» → вердикт «Apt conditionat» → заполнить «Recomandări» текстом → отправить
2. Открыть вкладку «Medical» сотрудника
**Ожидаем:** В колонке «RECOMANDĂRI» отображается введённый текст
---
### TC-31 — HR Admin не может записать вердикт
**Роль:** HR Admin
**Шаги:**
1. Войти как HR Admin → открыть «Inbox medic»
2. Нажать «Completează» → выбрать вердикт → отправить
**Ожидаем:** Toast с ошибкой «Forbidden resource», checkup остаётся в inbox
---
### TC-32 — После вердикта ULTIMUL CONTROL обновился
**Роль:** Medic Familie → затем HR Admin
**Шаги:**
1. Записать вердикт для periodic checkup
2. Войти как HR Admin → «Angajați» → сотрудник → вкладка «Medical»
**Ожидаем:** Поле «ULTIMUL CONTROL» = дата вердикта
---
### TC-33 — После вердикта добавился Anexa 6 Final
**Роль:** После записи вердикта
**Шаги:**
1. Записать вердикт → открыть вкладку «Medical» сотрудника → найти завершённый checkup
**Ожидаем:** В документах checkup появился новый файл «Anexa_6_Final_...» (4-й документ вместо 3-х)
---
## 6. Документы — скачивание и удаление
### TC-34 — Скачивание документа
**Роль:** HR Admin / HR Specialist
**Шаги:**
1. Вкладка «Medical» сотрудника → нажать «↓ Anexa_3_...» у любого checkup
**Ожидаем:** Открывается новая вкладка браузера, файл загружается (DOCX, не пустой)
---
### TC-35 — Удаление одного документа
**Роль:** HR Admin
**Шаги:**
1. В строке checkup нажать маленькую иконку корзины рядом с конкретным документом
2. Подтвердить (если есть подтверждение)
**Ожидаем:** Этот документ исчезает из списка, остальные документы строки остаются
---
### TC-36 — Удаление всех документов checkup («Toate»)
**Роль:** HR Admin
**Шаги:**
1. В строке checkup нажать кнопку «Toate» (удалить все)
2. Подтвердить
**Ожидаем:** Все документы строки исчезают, колонка «DOCUMENTE» пустая
---
### TC-37 — HR Specialist может удалять документы
**Роль:** HR Specialist
**Шаги:**
1. Открыть вкладку «Medical» сотрудника → удалить один документ
**Ожидаем:** Удаление проходит успешно, документ исчезает
---
### TC-38 — Medic Familie не видит кнопки удаления документов
**Роль:** Medic Familie
**Шаги:**
1. Войти как Medic Familie → открыть «Angajați» → вкладка «Medical»
**Ожидаем:** Иконки удаления документов недоступны / не отображаются
---
## 7. Удаление checkup
### TC-39 — Удаление checkup целиком
**Роль:** HR Admin
**Шаги:**
1. Вкладка «Medical» сотрудника → в строке checkup нажать большую красную корзину (удалить checkup)
2. Подтвердить
**Ожидаем:** Строка полностью исчезает из «Istoricul controlului medical»
---
### TC-40 — HR Specialist не видит кнопку удаления checkup
**Роль:** HR Specialist
**Шаги:**
1. Открыть вкладку «Medical» любого сотрудника
**Ожидаем:** Иконка удаления checkup (крайняя справа) недоступна или скрыта
---
## 8. Полный happy path (сквозной тест)
### TC-41 — Полный цикл от создания карты до вердикта
**Шаги:**
| # | Роль | Действие | Ожидаем |
|---|------|----------|---------|
| 1 | HR Admin | Carduri de risc → создать карту «Тест» с 2 факторами | Карта в таблице |
| 2 | HR Admin | Angajați → сотрудник → Medical → Editează → выбрать «Тест» → сохранить | Карта в профиле, ULTIMUL CONTROL пуст |
| 3 | HR Admin | Control medical → строка сотрудника → выбрать | Бейдж «NICIODATĂ», кнопка (1) |
| 4 | HR Admin | Generează documente → Periodic → сегодня → подтвердить | 3 документа в результате |
| 5 | HR Admin | Angajați → Medical → история | 1 строка IN AȘTEPTARE с 3 документами |
| 6 | Medic Familie | Inbox medic → Completează → Apt → Înregistrează | Toast успех, строка ушла из inbox |
| 7 | HR Admin | Angajați → Medical | ULTIMUL CONTROL = сегодня, вердикт APT, 4 документа |
| 8 | HR Admin | Control medical | Сотрудник получил статус «OK» (или пропал из таблицы) |
---
## 9. Статусы checkup в истории — визуальная проверка
| Состояние | Что видит пользователь в колонке VERDICT |
|-----------|------------------------------------------|
| Только создан, нет вердикта | Серый бейдж «IN AȘTEPTARE» |
| Вердикт «Apt» | Зелёный бейдж «APT» |
| Вердикт «Apt (perioadă adaptare)» | Бейдж «APT ADAPTARE» |
| Вердикт «Apt condiționat» | Бейдж «APT COND.» |
| Вердикт «Inapt temporar» | Оранжевый бейдж «INAPT TEMP.» |
| Вердикт «Inapt» | Красный бейдж «INAPT» |