# Contracts UI + Seed Data + Anexa Template Editor — 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. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Run seed data to populate reference dropdowns, build a standalone `/contracts` global listing page, and build a two-panel TipTap-based Anexa template editor for `hr_admin`. **Architecture:** `seed.ts` already has complete data — just run it. Contracts: new `ContractsGlobalModule` adds `GET /api/v1/contracts` with computed status (activ/expirat/expira_in_curand); `ContractsPage.tsx` renders a filterable table with a row-click drawer. Anexa editor: `AnexaTemplate` + `AnexaTemplateVersion` Prisma models → `AdminModule` → two-panel TipTap editor with custom `VariableChip` node → `tiptap-to-docx.ts` converter wired into `document-generator.service.ts`. **Tech Stack:** NestJS 10 + Prisma 5 + PostgreSQL 16, React 18 + Vite + Mantine v7 + TanStack Query v5, TipTap v2 (`@tiptap/react`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table`, `@tiptap/extension-underline`). --- ## File Map **Created:** - `apps/api/src/modules/contracts/contracts-global.controller.ts` - `apps/api/src/modules/contracts/contracts-global.service.ts` - `apps/api/src/modules/contracts/contracts-global.module.ts` - `apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts` - `apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts` - `apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts` - `apps/api/src/modules/admin/admin.module.ts` - `apps/web/src/pages/contracts/ContractsPage.tsx` - `apps/web/src/pages/admin/templates/TemplatesListPage.tsx` - `apps/web/src/pages/admin/templates/TemplateEditorPage.tsx` - `apps/web/src/pages/admin/templates/extensions/VariableChipExtension.tsx` - `apps/web/src/pages/admin/templates/components/VariableSidebar.tsx` - `apps/api/src/modules/medical/services/tiptap-to-docx.ts` **Modified:** - `apps/api/prisma/schema.prisma` — add `AnexaType` enum + 2 models - `apps/api/prisma/seed.ts` — add `AnexaTemplate` seed records - `apps/api/src/app.module.ts` — import 2 new modules - `apps/api/src/modules/employees/drawers/ContractDrawer.tsx` — invalidate `['contracts']` on success - `apps/api/src/modules/medical/services/document-generator.service.ts` — wire tiptap-to-docx - `apps/web/src/App.tsx` — add 2 nav items + 3 routes - `apps/web/src/api/types.ts` — add `ContractListItem`, `AnexaTemplate`, `AnexaTemplateVersion` - `apps/web/src/i18n/ro.json` — add `nav.contracts`, `nav.admin_templates` keys --- ## Task 1: Run Seed Data `seed.ts` at `apps/api/prisma/seed.ts` already contains complete records for `DisabilityGrade`, `TaxExemption`, `WorkSchedule`, and the full `Department` tree. This task just runs it. **Files:** Run existing `apps/api/prisma/seed.ts`. - [ ] **Step 1: Run the seed** ```bash epnpm db:sed ``` Expected output: ``` 🌱 Seeding reference data... ✓ DisabilityGrade (3) ✓ TaxExemption (7) ✓ WorkSchedule (7) ✓ Department (42) ✅ Seed complete. ``` - [ ] **Step 2: Verify in Prisma Studio** ```bash pnpm db:studio ``` Open http://localhost:5555. Check that `work_schedules`, `disability_grades`, `tax_exemptions`, and `departments` tables have rows. If `Department` shows 0, the DB was freshly reset — that's fine; the seed just ran successfully. - [ ] **Step 3: Commit** ```bash git add -A git commit -m "chore: run seed (no code changes; reference tables populated)" ``` If there are no code changes (seed.ts was already committed), skip the commit. --- ## Task 2: Contracts Global API — Service Create the service that fetches all contracts across employees with computed status. **Files:** - Create: `apps/api/src/modules/contracts/contracts-global.service.ts` - [ ] **Step 1: Create the service file** ```typescript // apps/api/src/modules/contracts/contracts-global.service.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../common/prisma/prisma.service'; import { AuditService } from '../../common/audit/audit.service'; export type ContractStatus = 'activ' | 'expirat' | 'expira_in_curand'; interface ListQuery { page: number; limit: number; departmentId?: string; perioada?: string; status?: ContractStatus; search?: string; } @Injectable() export class ContractsGlobalService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} async findAll(query: ListQuery, userId: string, role: string) { const { page, limit, departmentId, perioada, status, search } = query; const where: Record = {}; if (departmentId) where.departmentId = departmentId; if (perioada) where.perioada = perioada; if (search) { where.employee = { OR: [ { nume: { contains: search, mode: 'insensitive' } }, { prenume: { contains: search, mode: 'insensitive' } }, ], }; } const all = await this.prisma.employmentContract.findMany({ where, include: { employee: { select: { id: true, idnp: true, nume: true, prenume: true } }, department: { select: { id: true, name: true } }, workSchedule: { select: { id: true, name: true } }, categoriiServicii: true, }, orderBy: { dataAngajarii: 'desc' }, }); const now = new Date(); const withStatus = all .map((c) => ({ ...c, status: this.computeStatus(c, now) })) .filter((c) => !status || c.status === status); const total = withStatus.length; const items = withStatus.slice((page - 1) * limit, page * limit); await this.audit.logRead({ userId, userRole: role, entity: 'EmploymentContract', entityId: 'GLOBAL_LIST' }); return { total, page, limit, items }; } private computeStatus( c: { dataDemisiei: Date | null; perioada: string; dataTerminarii: Date | null }, now: Date, ): ContractStatus { if (c.dataDemisiei && c.dataDemisiei < now) return 'expirat'; if (c.perioada === 'determinata' && c.dataTerminarii) { const daysLeft = Math.floor((c.dataTerminarii.getTime() - now.getTime()) / 86_400_000); if (daysLeft < 0) return 'expirat'; if (daysLeft <= 30) return 'expira_in_curand'; } return 'activ'; } } ``` - [ ] **Step 2: Type-check** ```bash pnpm --filter api typecheck ``` Expected: no errors. --- ## Task 3: Contracts Global API — Controller + Module + App registration **Files:** - Create: `apps/api/src/modules/contracts/contracts-global.controller.ts` - Create: `apps/api/src/modules/contracts/contracts-global.module.ts` - Modify: `apps/api/src/app.module.ts` - [ ] **Step 1: Create the controller** ```typescript // apps/api/src/modules/contracts/contracts-global.controller.ts import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from '../../common/guards/roles.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { ContractsGlobalService, ContractStatus } from './contracts-global.service'; interface AuthReq extends Request { user: { id: string; role: string } } interface ContractsQuery { page?: string; limit?: string; departmentId?: string; perioada?: string; status?: ContractStatus; search?: string; } @Controller('contracts') @UseGuards(AuthGuard('jwt'), RolesGuard) export class ContractsGlobalController { constructor(private readonly svc: ContractsGlobalService) {} @Get() @Roles('hr_admin', 'hr_specialist') findAll(@Query() q: ContractsQuery, @Request() req: AuthReq) { return this.svc.findAll( { page: q.page ? Number(q.page) : 1, limit: q.limit ? Number(q.limit) : 50, departmentId: q.departmentId, perioada: q.perioada, status: q.status, search: q.search, }, req.user.id, req.user.role, ); } } ``` - [ ] **Step 2: Create the module** ```typescript // apps/api/src/modules/contracts/contracts-global.module.ts import { Module } from '@nestjs/common'; import { ContractsGlobalController } from './contracts-global.controller'; import { ContractsGlobalService } from './contracts-global.service'; @Module({ controllers: [ContractsGlobalController], providers: [ContractsGlobalService], }) export class ContractsGlobalModule {} ``` - [ ] **Step 3: Register in AppModule** In `apps/api/src/app.module.ts`, add the import after `DashboardModule`: ```typescript import { ContractsGlobalModule } from './modules/contracts/contracts-global.module'; // ... @Module({ imports: [ // ... existing imports ... DashboardModule, ContractsGlobalModule, // ADD THIS ], }) export class AppModule {} ``` - [ ] **Step 4: Type-check and start API** ```bash pnpm --filter api typecheck pnpm api:dev ``` - [ ] **Step 5: Smoke-test the endpoint** ```bash # Get a dev JWT first (if not already) curl -s -X POST http://localhost:3001/api/v1/auth/dev-login \ -H "Content-Type: application/json" \ -d '{"role":"hr_admin"}' | jq .token # Set TOKEN= curl -s http://localhost:3001/api/v1/contracts \ -H "Authorization: Bearer $TOKEN" | jq '{total: .total, count: (.items | length)}' ``` Expected: `{ total: N, count: N }` (N can be 0 if no contracts exist yet). - [ ] **Step 6: Commit** ```bash git add apps/api/src/modules/contracts/ apps/api/src/app.module.ts git commit -m "feat(api): add GET /contracts global listing endpoint with status filter" ``` --- ## Task 4: Add ContractListItem type + update ContractDrawer invalidation **Files:** - Modify: `apps/web/src/api/types.ts` - Modify: `apps/web/src/pages/employees/drawers/ContractDrawer.tsx` - [ ] **Step 1: Add ContractListItem to types.ts** After the `EmploymentContract` interface (line ~190), add: ```typescript export interface ContractListItem extends EmploymentContract { status: 'activ' | 'expirat' | 'expira_in_curand'; employee: Pick; } export interface PaginatedContracts { total: number; page: number; limit: number; items: ContractListItem[]; } ``` - [ ] **Step 2: Make ContractDrawer also invalidate the global contracts query** In `apps/web/src/pages/employees/drawers/ContractDrawer.tsx`, find the mutation's `onSuccess` callback (around line 133) and add one line: ```typescript onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); void qc.invalidateQueries({ queryKey: ['contracts'] }); // ADD THIS LINE notifications.show({ color: 'medpark', title: 'Salvat', message: isEdit ? 'Contract actualizat.' : 'Contract creat.' }); onClose(); }, ``` - [ ] **Step 3: Type-check** ```bash pnpm --filter web typecheck ``` --- ## Task 5: ContractsPage frontend **Files:** - Create: `apps/web/src/pages/contracts/ContractsPage.tsx` - [ ] **Step 1: Create the page** ```tsx // apps/web/src/pages/contracts/ContractsPage.tsx import { useState } from 'react'; import { Title, Table, Badge, Group, Text, TextInput, Select, Button, Box, Loader, Center, Modal, Anchor, } from '@mantine/core'; import { IconSearch, IconPlus } from '@tabler/icons-react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; import { apiClient } from '../../api/client'; import type { PaginatedContracts, ContractListItem, Department, PaginatedEmployees, EmployeeListItem } from '../../api/types'; import { ContractDrawer } from '../employees/drawers/ContractDrawer'; const font = "'Montserrat', Arial, sans-serif"; const teal = '#008286'; const STATUS_COLOR: Record = { activ: 'medpark', expirat: 'red', expira_in_curand: 'orange', }; const STATUS_LABEL: Record = { activ: 'Activ', expirat: 'Expirat', expira_in_curand: 'Expiră în curând', }; const PERIOD_LABEL: Record = { determinata: 'Determinată', nedeterminata: 'Nedeterminată', replasare_temporara: 'Înlocuire temp.', }; export function ContractsPage() { const navigate = useNavigate(); const [search, setSearch] = useState(''); const [deptFilter, setDeptFilter] = useState(null); const [periodaFilter, setPerioadaFilter] = useState(null); const [statusFilter, setStatusFilter] = useState(null); // Drawer state for view/edit const [drawerOpen, setDrawerOpen] = useState(false); const [selectedContract, setSelectedContract] = useState(); const [drawerEmployeeId, setDrawerEmployeeId] = useState(''); // Add-new modal: employee picker const [addModalOpen, setAddModalOpen] = useState(false); const [empSearch, setEmpSearch] = useState(''); const [pickedEmployeeId, setPickedEmployeeId] = useState(null); const params = new URLSearchParams(); if (search) params.set('search', search); if (deptFilter) params.set('departmentId', deptFilter); if (periodaFilter) params.set('perioada', periodaFilter); if (statusFilter) params.set('status', statusFilter); params.set('limit', '100'); const { data, isLoading } = useQuery({ queryKey: ['contracts', search, deptFilter, periodaFilter, statusFilter], queryFn: () => apiClient.get(`/contracts?${params.toString()}`).then((r) => r.data), staleTime: 30_000, }); const { data: depts } = useQuery({ queryKey: ['ref', 'departments-flat'], queryFn: () => apiClient.get('/reference/departments/flat').then((r) => r.data), staleTime: 300_000, }); const { data: empResults } = useQuery({ queryKey: ['employees-search', empSearch], queryFn: () => apiClient .get(`/employees?search=${encodeURIComponent(empSearch)}&limit=20`) .then((r) => r.data), enabled: empSearch.length >= 2, staleTime: 30_000, }); const deptData = (depts ?? []).map((d) => ({ value: d.id, label: d.name })); const empData = (empResults?.items ?? []).map((e: EmployeeListItem) => ({ value: e.id, label: `${e.nume} ${e.prenume} — ${e.idnp}`, })); function openRow(contract: ContractListItem) { setSelectedContract(contract); setDrawerEmployeeId(contract.employee.id); setDrawerOpen(true); } function startAdd() { setEmpSearch(''); setPickedEmployeeId(null); setAddModalOpen(true); } function confirmAdd() { if (!pickedEmployeeId) return; setAddModalOpen(false); setSelectedContract(undefined); setDrawerEmployeeId(pickedEmployeeId); setDrawerOpen(true); } return ( Contracte {/* Filters */} } value={search} onChange={(e) => setSearch(e.currentTarget.value)} style={{ width: 260 }} styles={{ input: { fontFamily: font } }} /> {/* Contract view/edit drawer */} {drawerEmployeeId && ( { setDrawerOpen(false); setSelectedContract(undefined); }} /> )} ); } ``` - [ ] **Step 2: Type-check** ```bash pnpm --filter web typecheck ``` Expected: no errors. --- ## Task 6: Wire ContractsPage into App.tsx + i18n **Files:** - Modify: `apps/web/src/App.tsx` - Modify: `apps/web/src/i18n/ro.json` (find this file and add the keys) - [ ] **Step 1: Add import and nav item in App.tsx** At the top of `apps/web/src/App.tsx`, add the import after the existing page imports: ```typescript import { IconFileDescription } from '@tabler/icons-react'; import { ContractsPage } from './pages/contracts/ContractsPage'; ``` In the `NAV_ITEMS` array, add after the `nav.departments` entry: ```typescript { labelKey: 'nav.contracts', path: '/contracts', icon: }, ``` In the `` section, add after the departments route: ```tsx } /> ``` - [ ] **Step 2: Add i18n keys** Find `apps/web/src/i18n/ro.json` and add to the `nav` section: ```json "contracts": "Contracte", "admin_templates": "Șabloane Anexa" ``` If the file doesn't exist or doesn't have a `nav` object, create/update it to follow the existing pattern. - [ ] **Step 3: Start both servers and test manually** ```bash pnpm dev ``` Open http://localhost:5173, log in as `hr_admin`. Verify "Contracte" appears in the left nav. Click it — should show an empty table (if no contracts) or contracts list. Test filters (they should not error). - [ ] **Step 4: Commit** ```bash git add apps/web/src/pages/contracts/ apps/web/src/App.tsx apps/web/src/api/types.ts apps/web/src/i18n/ apps/web/src/pages/employees/drawers/ContractDrawer.tsx git commit -m "feat: Contracts standalone page — global listing with status badges and filters" ``` --- ## Task 7: Prisma schema — AnexaTemplate models **Files:** - Modify: `apps/api/prisma/schema.prisma` - [ ] **Step 1: Add enum and models to schema.prisma** At the end of the ENUMS section (after `MedicalVerdict`), add: ```prisma enum AnexaType { ANEXA_3 ANEXA_4 ANEXA_4B ANEXA_6 } ``` At the end of the file (after the `AuditLog` model), add: ```prisma // ═══════════════════════════════════════════════════════════════ // ANEXA TEMPLATE EDITOR // ═══════════════════════════════════════════════════════════════ model AnexaTemplate { id String @id @default(uuid()) type AnexaType @unique name String contentJson Json updatedById String updatedAt DateTime @updatedAt versions AnexaTemplateVersion[] @@map("anexa_templates") } model AnexaTemplateVersion { id String @id @default(uuid()) templateId String template AnexaTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) contentJson Json savedById String savedAt DateTime @default(now()) label String? @@index([templateId]) @@map("anexa_template_versions") } ``` - [ ] **Step 2: Run migration** ```bash pnpm --filter api exec prisma migrate dev --name add_anexa_templates ``` Expected output: ``` ✔ Generated Prisma Client The following migration(s) have been applied: 20260508_add_anexa_templates ``` - [ ] **Step 3: Verify Prisma Studio shows the new tables** ```bash pnpm db:studio ``` Tables `anexa_templates` and `anexa_template_versions` should appear. Both empty. - [ ] **Step 4: Commit** ```bash git add apps/api/prisma/ git commit -m "feat(db): add AnexaTemplate + AnexaTemplateVersion models with AnexaType enum" ``` --- ## Task 8: AnexaTemplates NestJS module (API) **Files:** - Create: `apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts` - Create: `apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts` - Create: `apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts` - Create: `apps/api/src/modules/admin/admin.module.ts` - Modify: `apps/api/src/app.module.ts` - [ ] **Step 1: Create the DTO** ```typescript // apps/api/src/modules/admin/anexa-templates/dto/update-template.dto.ts import { IsOptional, IsString } from 'class-validator'; export class UpdateTemplateDto { // contentJson is a TipTap JSON document object contentJson: unknown; @IsOptional() @IsString() name?: string; } ``` - [ ] **Step 2: Create the service** ```typescript // apps/api/src/modules/admin/anexa-templates/anexa-templates.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { AnexaType } from '@prisma/client'; import { PrismaService } from '../../../common/prisma/prisma.service'; import { AuditService } from '../../../common/audit/audit.service'; import { UpdateTemplateDto } from './dto/update-template.dto'; const ANEXA_NAMES: Record = { ANEXA_3: 'Fișa de solicitare a examenului medical', ANEXA_4: 'Fișa de evaluare a locului de muncă', ANEXA_4B: 'Supliment radiații ionizante', ANEXA_6: 'Verdict medic de familie', }; @Injectable() export class AnexaTemplatesService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} list() { return this.prisma.anexaTemplate.findMany({ select: { id: true, type: true, name: true, updatedById: true, updatedAt: true }, orderBy: { type: 'asc' }, }); } async findOne(type: AnexaType) { const t = await this.prisma.anexaTemplate.findUnique({ where: { type } }); if (!t) throw new NotFoundException(`Template ${type} nu există`); return t; } async update(type: AnexaType, dto: UpdateTemplateDto, userId: string, role: string) { const existing = await this.prisma.anexaTemplate.findUnique({ where: { type } }); // Snapshot current version before overwriting if (existing) { await this.prisma.anexaTemplateVersion.create({ data: { templateId: existing.id, contentJson: existing.contentJson as never, savedById: userId, }, }); } const template = await this.prisma.anexaTemplate.upsert({ where: { type }, update: { contentJson: dto.contentJson as never, updatedById: userId, ...(dto.name ? { name: dto.name } : {}), }, create: { type, name: dto.name ?? ANEXA_NAMES[type], contentJson: dto.contentJson as never, updatedById: userId, }, }); await this.audit.logChange({ userId, userRole: role, action: 'UPDATE', entity: 'AnexaTemplate', entityId: template.id, }); return template; } getVersions(type: AnexaType) { return this.prisma.anexaTemplateVersion.findMany({ where: { template: { type } }, orderBy: { savedAt: 'desc' }, take: 50, }); } async restore(type: AnexaType, versionId: string, userId: string, role: string) { const version = await this.prisma.anexaTemplateVersion.findUniqueOrThrow({ where: { id: versionId }, }); return this.update(type, { contentJson: version.contentJson }, userId, role); } async getPreviewEmployee() { return this.prisma.employee.findFirst({ select: { id: true, idnp: true, nume: true, prenume: true, dataNasterii: true, contracts: { select: { functiaOrganigrama: true, functiaClasificator: true, department: { select: { name: true } } }, orderBy: { dataAngajarii: 'desc' }, take: 1, }, medicalProfile: { select: { ocupatieCorm: true, dozaCumulataExternaMsv: true, dozaCumulataInternaMsv: true, }, }, }, orderBy: { createdAt: 'desc' }, }); } } ``` - [ ] **Step 3: Create the controller** ```typescript // apps/api/src/modules/admin/anexa-templates/anexa-templates.controller.ts import { Controller, Get, Put, Post, Body, Param, UseGuards, Request, HttpCode, HttpStatus, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AnexaType } from '@prisma/client'; import { RolesGuard } from '../../../common/guards/roles.guard'; import { Roles } from '../../../common/decorators/roles.decorator'; import { AnexaTemplatesService } from './anexa-templates.service'; import { UpdateTemplateDto } from './dto/update-template.dto'; interface AuthReq extends Request { user: { id: string; role: string } } @Controller('admin/anexa-templates') @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('hr_admin') export class AnexaTemplatesController { constructor(private readonly svc: AnexaTemplatesService) {} // IMPORTANT: literal routes before :type to avoid Fastify routing conflict @Get('preview-employee') getPreviewEmployee() { return this.svc.getPreviewEmployee(); } @Get() list() { return this.svc.list(); } @Get(':type') findOne(@Param('type') type: AnexaType) { return this.svc.findOne(type); } @Put(':type') update(@Param('type') type: AnexaType, @Body() dto: UpdateTemplateDto, @Request() req: AuthReq) { return this.svc.update(type, dto, req.user.id, req.user.role); } @Get(':type/versions') getVersions(@Param('type') type: AnexaType) { return this.svc.getVersions(type); } @Post(':type/restore/:versionId') @HttpCode(HttpStatus.OK) restore( @Param('type') type: AnexaType, @Param('versionId') versionId: string, @Request() req: AuthReq, ) { return this.svc.restore(type, versionId, req.user.id, req.user.role); } } ``` - [ ] **Step 4: Create AdminModule** ```typescript // apps/api/src/modules/admin/admin.module.ts import { Module } from '@nestjs/common'; import { AnexaTemplatesController } from './anexa-templates/anexa-templates.controller'; import { AnexaTemplatesService } from './anexa-templates/anexa-templates.service'; @Module({ controllers: [AnexaTemplatesController], providers: [AnexaTemplatesService], }) export class AdminModule {} ``` - [ ] **Step 5: Register AdminModule in AppModule** In `apps/api/src/app.module.ts`: ```typescript import { AdminModule } from './modules/admin/admin.module'; // ... @Module({ imports: [ // ... existing ... ContractsGlobalModule, AdminModule, // ADD THIS ], }) ``` - [ ] **Step 6: Type-check** ```bash pnpm --filter api typecheck ``` Expected: no errors. - [ ] **Step 7: Commit** ```bash git add apps/api/src/modules/admin/ apps/api/src/app.module.ts git commit -m "feat(api): AnexaTemplatesModule — CRUD templates with version history" ``` --- ## Task 9: Seed initial Anexa templates **Files:** - Modify: `apps/api/prisma/seed.ts` - [ ] **Step 1: Add AnexaTemplate seed at the end of `main()` in seed.ts** After the Department seeding block and before `console.log('\n✅ Seed complete.')`, add: ```typescript // ── Anexa Templates — minimal seed (HR admins expand in editor) ─ const minimalDoc = (title: string, subtitle?: string) => ({ type: 'doc', content: [ { type: 'heading', attrs: { level: 2, textAlign: 'center' }, content: [{ type: 'text', text: title }], }, ...(subtitle ? [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [{ type: 'text', text: subtitle }], }] : []), { type: 'paragraph', content: [ { type: 'text', text: 'Unitatea economică: ' }, { type: 'variableChip', attrs: { key: 'company.name', label: 'Denumirea unității' } }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'Angajat: ' }, { type: 'variableChip', attrs: { key: 'employee.firstName', label: 'Prenumele' } }, { type: 'text', text: ' ' }, { type: 'variableChip', attrs: { key: 'employee.lastName', label: 'Numele' } }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'Data: ' }, { type: 'variableChip', attrs: { key: 'document.date', label: 'Data documentului' } }, ], }, ], }); const SEED_SYSTEM_USER = '00000000-0000-0000-0000-000000000000'; await prisma.anexaTemplate.upsert({ where: { type: 'ANEXA_3' }, update: {}, create: { type: 'ANEXA_3', name: 'Fișa de solicitare a examenului medical', contentJson: minimalDoc('FIȘA DE SOLICITARE', 'a examenului medical la angajare / periodic') as never, updatedById: SEED_SYSTEM_USER, }, }); await prisma.anexaTemplate.upsert({ where: { type: 'ANEXA_4' }, update: {}, create: { type: 'ANEXA_4', name: 'Fișa de evaluare a locului de muncă', contentJson: minimalDoc('FIȘA DE EVALUARE', 'a locului de muncă') as never, updatedById: SEED_SYSTEM_USER, }, }); await prisma.anexaTemplate.upsert({ where: { type: 'ANEXA_4B' }, update: {}, create: { type: 'ANEXA_4B', name: 'Supliment radiații ionizante', contentJson: minimalDoc('SUPLIMENT', 'expunere la radiații ionizante') as never, updatedById: SEED_SYSTEM_USER, }, }); await prisma.anexaTemplate.upsert({ where: { type: 'ANEXA_6' }, update: {}, create: { type: 'ANEXA_6', name: 'Verdict medic de familie', contentJson: minimalDoc('VERDICT', 'al medicului de familie') as never, updatedById: SEED_SYSTEM_USER, }, }); console.log(' ✓ AnexaTemplate (4)'); ``` - [ ] **Step 2: Re-run seed** ```bash pnpm db:seed ``` Expected: `✓ AnexaTemplate (4)` in output. If DisabilityGrade etc. show "already exists" errors, that's fine — `skipDuplicates: true` handles it. - [ ] **Step 3: Commit** ```bash git add apps/api/prisma/seed.ts git commit -m "feat(seed): add minimal AnexaTemplate seeds for all 4 Anexa types" ``` --- ## Task 10: Install TipTap packages **Files:** `apps/web/package.json` (modified by pnpm) - [ ] **Step 1: Install packages** ```bash pnpm --filter web add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-table @tiptap/extension-underline ``` - [ ] **Step 2: Verify import resolves** ```bash pnpm --filter web typecheck ``` Expected: no errors related to missing @tiptap modules. --- ## Task 11: VariableChip TipTap extension **Files:** - Create: `apps/web/src/pages/admin/templates/extensions/VariableChipExtension.tsx` - [ ] **Step 1: Create the extension file** ```tsx // apps/web/src/pages/admin/templates/extensions/VariableChipExtension.tsx import { Node, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; // Color by variable namespace const NS_COLORS: Record = { company: '#008286', employee: '#008286', document: '#4338ca', row: '#c05621', radiation: '#9d174d', }; interface ChipAttrs { key: string; label: string } // Chip pill — used in the editable left panel function ChipView({ node }: { node: { attrs: ChipAttrs } }) { const ns = node.attrs.key.split('.')[0]; const bg = NS_COLORS[ns] ?? '#6b7280'; return ( {node.attrs.label} ); } // Resolved value — used in the read-only right preview panel function PreviewView({ node, vars }: { node: { attrs: ChipAttrs }; vars: Record }) { const value = vars[node.attrs.key] ?? `[${node.attrs.key}]`; return ( {value} ); } /** * Factory that returns a TipTap Node extension. * - No args → editor mode (colored pill chips). * - Pass `vars` → preview mode (renders resolved values, read-only). */ export function createVariableChipExtension(vars?: Record) { // Stable component reference captured per factory call const ViewComponent = vars ? function PV({ node }: { node: { attrs: ChipAttrs } }) { return ; } : ChipView; return Node.create({ name: 'variableChip', group: 'inline', inline: true, atom: true, addAttributes() { return { key: { default: '' }, label: { default: '' }, }; }, parseHTML() { return [{ tag: 'span[data-variable-chip]' }]; }, renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes(HTMLAttributes, { 'data-variable-chip': '' })]; }, addNodeView() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return ReactNodeViewRenderer(ViewComponent as any); }, }); } // All variables available for insertion, grouped by namespace export const VARIABLE_GROUPS = [ { label: 'Companie', color: '#008286', vars: [ { key: 'company.name', label: 'Denumirea unității' }, { key: 'company.idno', label: 'IDNO' }, { key: 'company.address', label: 'Adresa' }, ], }, { label: 'Document', color: '#4338ca', vars: [ { key: 'document.date', label: 'Data documentului' }, { key: 'document.number', label: 'Numărul documentului' }, ], }, { label: 'Angajat', color: '#008286', vars: [ { key: 'employee.lastName', label: 'Numele' }, { key: 'employee.firstName', label: 'Prenumele' }, { key: 'employee.idnp', label: 'IDNP' }, { key: 'employee.birthYear', label: 'Anul nașterii' }, { key: 'employee.occupation', label: 'Ocupația (CORM)' }, { key: 'employee.department', label: 'Secția' }, ], }, { label: 'Rând tabel', color: '#c05621', vars: [ { key: 'row.index', label: 'Nr. crt.' }, { key: 'row.seatNumber', label: 'Nr. loc de muncă' }, { key: 'row.employeeName', label: 'Numele angajatului' }, { key: 'row.riskFactors', label: 'Factorii de risc' }, ], }, { label: 'Radiații', color: '#9d174d', vars: [ { key: 'radiation.externalMsv', label: 'Doza externă (mSv)' }, { key: 'radiation.internalMsv', label: 'Doza internă (mSv)' }, ], }, ] as const; ``` - [ ] **Step 2: Type-check** ```bash pnpm --filter web typecheck ``` --- ## Task 12: Add Anexa types to types.ts **Files:** - Modify: `apps/web/src/api/types.ts` - [ ] **Step 1: Append Anexa interfaces to the end of types.ts** ```typescript // ─── Anexa Templates ───────────────────────────────────────── export type AnexaType = 'ANEXA_3' | 'ANEXA_4' | 'ANEXA_4B' | 'ANEXA_6'; export interface AnexaTemplateMeta { id: string; type: AnexaType; name: string; updatedById: string; updatedAt: string; } export interface AnexaTemplate extends AnexaTemplateMeta { contentJson: unknown; // TipTap JSON document } export interface AnexaTemplateVersion { id: string; templateId: string; contentJson: unknown; savedById: string; savedAt: string; label: string | null; } export interface PreviewEmployee { id: string; idnp: string; nume: string; prenume: string; dataNasterii: string; contracts: { functiaOrganigrama: string | null; functiaClasificator: string | null; department: { name: string }; }[]; medicalProfile: { ocupatieCorm: string | null; dozaCumulataExternaMsv: string | null; dozaCumulataInternaMsv: string | null; } | null; } ``` --- ## Task 13: TemplatesListPage **Files:** - Create: `apps/web/src/pages/admin/templates/TemplatesListPage.tsx` - [ ] **Step 1: Create the list page** ```tsx // apps/web/src/pages/admin/templates/TemplatesListPage.tsx import { Box, Title, SimpleGrid, Card, Text, Badge, Loader, Center } from '@mantine/core'; import { IconFileText } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; import { apiClient } from '../../../api/client'; import type { AnexaTemplateMeta } from '../../../api/types'; const font = "'Montserrat', Arial, sans-serif"; const teal = '#008286'; const TYPE_LABELS: Record = { ANEXA_3: 'Anexa 3', ANEXA_4: 'Anexa 4', ANEXA_4B: 'Anexa 4B', ANEXA_6: 'Anexa 6', }; export function TemplatesListPage() { const navigate = useNavigate(); const { data, isLoading } = useQuery({ queryKey: ['admin', 'anexa-templates'], queryFn: () => apiClient.get('/admin/anexa-templates').then((r) => r.data), staleTime: 60_000, }); if (isLoading) { return
; } return ( Șabloane Anexa Editați structura și conținutul documentelor DOCX generate automat. {(data ?? []).map((t) => ( navigate(`/admin/templates/${t.type}`)} style={{ cursor: 'pointer', borderLeft: `4px solid ${teal}`, fontFamily: font }} > {TYPE_LABELS[t.type]} {t.name} Actualizat: {dayjs(t.updatedAt).format('DD.MM.YYYY HH:mm')} ))} ); } ``` --- ## Task 14: TemplateEditorPage (two-panel TipTap editor) **Files:** - Create: `apps/web/src/pages/admin/templates/TemplateEditorPage.tsx` - Create: `apps/web/src/pages/admin/templates/components/VariableSidebar.tsx` - [ ] **Step 1: Create VariableSidebar** ```tsx // apps/web/src/pages/admin/templates/components/VariableSidebar.tsx import { Box, Text, Button, Stack, ScrollArea } from '@mantine/core'; import type { Editor } from '@tiptap/react'; import { VARIABLE_GROUPS } from '../extensions/VariableChipExtension'; const font = "'Montserrat', Arial, sans-serif"; export function VariableSidebar({ editor }: { editor: Editor }) { function insertChip(key: string, label: string) { editor.chain().focus().insertContent({ type: 'variableChip', attrs: { key, label }, }).run(); } return ( Variabile disponibile {VARIABLE_GROUPS.map((group) => ( {group.label} {group.vars.map((v) => ( ))} ))} ); } ``` - [ ] **Step 2: Create TemplateEditorPage** ```tsx // apps/web/src/pages/admin/templates/TemplateEditorPage.tsx import { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Box, Button, Group, Text, Loader, Center, Divider, ActionIcon, Tooltip, Modal, Select, ScrollArea, Paper, } from '@mantine/core'; import { IconArrowLeft, IconDeviceFloppy, IconHistory, IconRefresh } from '@tabler/icons-react'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { Table } from '@tiptap/extension-table'; import { TableRow } from '@tiptap/extension-table/dist/table-row'; import { TableCell } from '@tiptap/extension-table/dist/table-cell'; import { TableHeader } from '@tiptap/extension-table/dist/table-header'; import Underline from '@tiptap/extension-underline'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { notifications } from '@mantine/notifications'; import dayjs from 'dayjs'; import { apiClient } from '../../../api/client'; import type { AnexaTemplate, AnexaTemplateVersion, AnexaType, PreviewEmployee } from '../../../api/types'; import { createVariableChipExtension, VARIABLE_GROUPS } from './extensions/VariableChipExtension'; import { VariableSidebar } from './components/VariableSidebar'; const font = "'Montserrat', Arial, sans-serif"; const teal = '#008286'; const TYPE_LABELS: Record = { ANEXA_3: 'Anexa 3', ANEXA_4: 'Anexa 4', ANEXA_4B: 'Anexa 4B', ANEXA_6: 'Anexa 6', }; function buildVars(emp: PreviewEmployee | null | undefined): Record { if (!emp) return {}; const contract = emp.contracts[0]; return { 'company.name': 'Medpark International Hospital', 'company.idno': '1003600035476', 'company.address': 'mun. Chișinău, str. Păcii 1', 'document.date': dayjs().format('DD.MM.YYYY'), 'document.number': '001', 'employee.lastName': emp.nume, 'employee.firstName': emp.prenume, 'employee.idnp': emp.idnp, 'employee.birthYear': dayjs(emp.dataNasterii).format('YYYY'), 'employee.occupation': emp.medicalProfile?.ocupatieCorm ?? contract?.functiaClasificator ?? '—', 'employee.department': contract?.department?.name ?? '—', 'row.index': '1', 'row.seatNumber': '1', 'row.employeeName': `${emp.prenume} ${emp.nume}`, 'row.riskFactors': 'Factori chimici, biologici', 'radiation.externalMsv': emp.medicalProfile?.dozaCumulataExternaMsv ?? '0.00', 'radiation.internalMsv': emp.medicalProfile?.dozaCumulataInternaMsv ?? '0.00', }; } export function TemplateEditorPage() { const { type } = useParams<{ type: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); const [showVersions, setShowVersions] = useState(false); const [previewEmployee, setPreviewEmployee] = useState(null); const [previewKey, setPreviewKey] = useState(0); // bump to recreate preview editor const [empPickerOpen, setEmpPickerOpen] = useState(false); const [empSearch, setEmpSearch] = useState(''); const [pickedEmpId, setPickedEmpId] = useState(null); const { data: template, isLoading } = useQuery({ queryKey: ['admin', 'anexa-templates', type], queryFn: () => apiClient.get(`/admin/anexa-templates/${type!}`).then((r) => r.data), enabled: !!type, }); const { data: versions } = useQuery({ queryKey: ['admin', 'anexa-template-versions', type], queryFn: () => apiClient.get(`/admin/anexa-templates/${type!}/versions`).then((r) => r.data), enabled: showVersions && !!type, }); const { data: previewEmp } = useQuery({ queryKey: ['admin', 'preview-employee'], queryFn: () => apiClient.get('/admin/anexa-templates/preview-employee').then((r) => r.data), staleTime: 300_000, }); useEffect(() => { if (previewEmp && !previewEmployee) { setPreviewEmployee(previewEmp); } }, [previewEmp, previewEmployee]); // Left editor — editable const leftEditor = useEditor({ extensions: [ StarterKit, Underline, Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, createVariableChipExtension(), // chip mode ], content: template?.contentJson ?? { type: 'doc', content: [] }, }); // Load template content once fetched useEffect(() => { if (template && leftEditor && leftEditor.isEmpty) { leftEditor.commands.setContent(template.contentJson as never); } }, [template, leftEditor]); // Preview editor — read-only, recreated when previewEmployee changes const vars = buildVars(previewEmployee); const previewEditor = useEditor( { extensions: [ StarterKit, Underline, Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, createVariableChipExtension(vars), // preview mode with resolved values ], editable: false, content: leftEditor?.getJSON() ?? { type: 'doc', content: [] }, }, [previewKey], // recreate when employee changes ); // Sync left → preview on every left editor change useEffect(() => { if (!leftEditor || !previewEditor) return; const handler = () => { previewEditor.commands.setContent(leftEditor.getJSON()); }; leftEditor.on('update', handler); return () => { leftEditor.off('update', handler); }; }, [leftEditor, previewEditor]); const saveMutation = useMutation({ mutationFn: () => apiClient.put(`/admin/anexa-templates/${type!}`, { contentJson: leftEditor?.getJSON() }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['admin', 'anexa-templates'] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Șablonul a fost salvat.' }); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare'; notifications.show({ color: 'red', title: 'Eroare', message: msg }); }, }); const restoreMutation = useMutation({ mutationFn: (versionId: string) => apiClient.post(`/admin/anexa-templates/${type!}/restore/${versionId}`), onSuccess: (res) => { leftEditor?.commands.setContent(res.data.contentJson as never); void qc.invalidateQueries({ queryKey: ['admin', 'anexa-templates', type] }); notifications.show({ color: 'medpark', title: 'Restaurat', message: 'Versiune restaurată.' }); setShowVersions(false); }, }); const { data: empSearchResults } = useQuery({ queryKey: ['employees-search', empSearch], queryFn: () => apiClient.get<{ items: { id: string; idnp: string; nume: string; prenume: string }[] }>( `/employees?search=${encodeURIComponent(empSearch)}&limit=20`, ).then((r) => r.data), enabled: empSearch.length >= 2, staleTime: 30_000, }); function changePreviewEmployee() { const found = empSearchResults?.items.find((e) => e.id === pickedEmpId); if (!found) return; // Fetch full preview data void apiClient .get(`/admin/anexa-templates/preview-employee?employeeId=${found.id}`) .then((r) => { setPreviewEmployee(r.data); setPreviewKey((k) => k + 1); setEmpPickerOpen(false); }); } if (isLoading) return
; return ( {/* Top bar */} navigate('/admin/templates')}> {TYPE_LABELS[type ?? ''] ?? type} {template?.name} setShowVersions((v) => !v)}> {/* Formatting toolbar */} {leftEditor && ( Inserează: {VARIABLE_GROUPS.slice(0, 3).map((g) => ( ))} )} {/* Split body */} {/* Left: TipTap editor */} {leftEditor && } {/* Right: Preview */} 👁 Preview {previewEditor && } {/* Variable sidebar or version history */} {showVersions ? ( Versiuni {(versions ?? []).map((v) => ( {dayjs(v.savedAt).format('DD.MM.YYYY HH:mm')} {v.label && {v.label}} ))} {(versions ?? []).length === 0 && ( Fără versiuni salvate. )} ) : ( leftEditor && )} {/* Change preview employee modal */} setEmpPickerOpen(false)} title={Schimbă angajatul test} size="sm">