Files
hrm-medpark/docs/superpowers/plans/2026-05-08-contracts-seed-anexa.md
T
Danil Suhomlinov 33800292aa 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>
2026-06-08 17:42:45 +03:00

2195 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, unknown> = {};
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=<value from above>
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<Employee, 'id' | 'idnp' | 'nume' | 'prenume'>;
}
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<string, string> = {
activ: 'medpark',
expirat: 'red',
expira_in_curand: 'orange',
};
const STATUS_LABEL: Record<string, string> = {
activ: 'Activ',
expirat: 'Expirat',
expira_in_curand: 'Expiră în curând',
};
const PERIOD_LABEL: Record<string, string> = {
determinata: 'Determinată',
nedeterminata: 'Nedeterminată',
replasare_temporara: 'Înlocuire temp.',
};
export function ContractsPage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [deptFilter, setDeptFilter] = useState<string | null>(null);
const [periodaFilter, setPerioadaFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
// Drawer state for view/edit
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedContract, setSelectedContract] = useState<ContractListItem | undefined>();
const [drawerEmployeeId, setDrawerEmployeeId] = useState('');
// Add-new modal: employee picker
const [addModalOpen, setAddModalOpen] = useState(false);
const [empSearch, setEmpSearch] = useState('');
const [pickedEmployeeId, setPickedEmployeeId] = useState<string | null>(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<PaginatedContracts>(`/contracts?${params.toString()}`).then((r) => r.data),
staleTime: 30_000,
});
const { data: depts } = useQuery({
queryKey: ['ref', 'departments-flat'],
queryFn: () => apiClient.get<Department[]>('/reference/departments/flat').then((r) => r.data),
staleTime: 300_000,
});
const { data: empResults } = useQuery({
queryKey: ['employees-search', empSearch],
queryFn: () =>
apiClient
.get<PaginatedEmployees>(`/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 (
<Box>
<Group justify="space-between" mb={24}>
<Box>
<Title order={2} style={{ fontFamily: font, color: '#58595b' }}>
Contracte
</Title>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 4 }} />
</Box>
<Button
leftSection={<IconPlus size={16} />}
onClick={startAdd}
style={{ background: teal, fontFamily: font, fontWeight: 500, height: 40 }}
>
Adaugă contract
</Button>
</Group>
{/* Filters */}
<Group mb={16} gap={12} wrap="wrap">
<TextInput
placeholder="Caută după angajat..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ width: 260 }}
styles={{ input: { fontFamily: font } }}
/>
<Select
placeholder="Departament"
data={deptData}
value={deptFilter}
onChange={setDeptFilter}
clearable
searchable
style={{ width: 220 }}
styles={{ input: { fontFamily: font } }}
/>
<Select
placeholder="Perioadă"
data={[
{ value: 'determinata', label: 'Determinată' },
{ value: 'nedeterminata', label: 'Nedeterminată' },
{ value: 'replasare_temporara', label: 'Înlocuire temporară' },
]}
value={periodaFilter}
onChange={setPerioadaFilter}
clearable
style={{ width: 200 }}
styles={{ input: { fontFamily: font } }}
/>
<Select
placeholder="Status"
data={[
{ value: 'activ', label: 'Activ' },
{ value: 'expirat', label: 'Expirat' },
{ value: 'expira_in_curand', label: 'Expiră în curând' },
]}
value={statusFilter}
onChange={setStatusFilter}
clearable
style={{ width: 200 }}
styles={{ input: { fontFamily: font } }}
/>
</Group>
{isLoading ? (
<Center h={200}><Loader color={teal} /></Center>
) : (
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ fontFamily: font, fontSize: '0.875rem' }}>
<Table.Thead style={{ borderBottom: `2px solid ${teal}` }}>
<Table.Tr>
<Table.Th>Nr. CIM</Table.Th>
<Table.Th>Angajat</Table.Th>
<Table.Th>Funcția</Table.Th>
<Table.Th>Secția</Table.Th>
<Table.Th>Perioadă</Table.Th>
<Table.Th>Data angajării</Table.Th>
<Table.Th>Data terminării</Table.Th>
<Table.Th>Salarizare</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data?.items ?? []).map((c) => (
<Table.Tr
key={c.id}
onClick={() => openRow(c)}
style={{ cursor: 'pointer', borderBottom: '1px solid #e9ecef' }}
>
<Table.Td fw={600} c={teal}>{c.nrCim}</Table.Td>
<Table.Td>
<Anchor
onClick={(e) => { e.stopPropagation(); navigate(`/employees/${c.employee.id}`); }}
style={{ fontFamily: font, color: teal, fontWeight: 500 }}
>
{c.employee.nume} {c.employee.prenume}
</Anchor>
</Table.Td>
<Table.Td>{c.functiaOrganigrama ?? '—'}</Table.Td>
<Table.Td>{c.department.name}</Table.Td>
<Table.Td>{PERIOD_LABEL[c.perioada]}</Table.Td>
<Table.Td>{dayjs(c.dataAngajarii).format('DD.MM.YYYY')}</Table.Td>
<Table.Td>
{c.dataTerminarii
? dayjs(c.dataTerminarii).format('DD.MM.YYYY')
: c.dataDemisiei
? dayjs(c.dataDemisiei).format('DD.MM.YYYY')
: '—'}
</Table.Td>
<Table.Td>{c.tipSalarizare ?? '—'}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[c.status]} size="sm" style={{ fontFamily: font }}>
{STATUS_LABEL[c.status]}
</Badge>
</Table.Td>
</Table.Tr>
))}
{(data?.items ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py={24} style={{ fontFamily: font }}>
Niciun contract găsit.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
)}
{/* Employee picker modal for Add */}
<Modal
opened={addModalOpen}
onClose={() => setAddModalOpen(false)}
title={<Text fw={600} style={{ fontFamily: font }}>Selectează angajatul</Text>}
size="md"
>
<Select
label="Caută angajat (min. 2 caractere)"
placeholder="Ionescu Maria..."
searchable
data={empData}
value={pickedEmployeeId}
onChange={setPickedEmployeeId}
onSearchChange={setEmpSearch}
searchValue={empSearch}
nothingFoundMessage={empSearch.length < 2 ? 'Introdu cel puțin 2 caractere' : 'Niciun rezultat'}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<Group justify="flex-end" mt={16}>
<Button variant="subtle" onClick={() => setAddModalOpen(false)} style={{ fontFamily: font }}>
Anulează
</Button>
<Button
disabled={!pickedEmployeeId}
onClick={confirmAdd}
style={{ background: teal, fontFamily: font }}
>
Continuă
</Button>
</Group>
</Modal>
{/* Contract view/edit drawer */}
{drawerEmployeeId && (
<ContractDrawer
employeeId={drawerEmployeeId}
record={selectedContract}
opened={drawerOpen}
onClose={() => { setDrawerOpen(false); setSelectedContract(undefined); }}
/>
)}
</Box>
);
}
```
- [ ] **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: <IconFileDescription size={20} stroke={1.6} color="#008286" /> },
```
In the `<Routes>` section, add after the departments route:
```tsx
<Route path="/contracts" element={<ContractsPage />} />
```
- [ ] **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<AnexaType, string> = {
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<string, string> = {
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 (
<NodeViewWrapper as="span" style={{ display: 'inline-block', lineHeight: 1 }}>
<span
contentEditable={false}
style={{
background: bg,
color: 'white',
borderRadius: 10,
padding: '2px 10px',
fontSize: '0.72rem',
fontFamily: 'sans-serif',
userSelect: 'none',
cursor: 'default',
}}
>
{node.attrs.label}
</span>
</NodeViewWrapper>
);
}
// Resolved value — used in the read-only right preview panel
function PreviewView({ node, vars }: { node: { attrs: ChipAttrs }; vars: Record<string, string> }) {
const value = vars[node.attrs.key] ?? `[${node.attrs.key}]`;
return (
<NodeViewWrapper as="span">
<strong>{value}</strong>
</NodeViewWrapper>
);
}
/**
* 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<string, string>) {
// Stable component reference captured per factory call
const ViewComponent = vars
? function PV({ node }: { node: { attrs: ChipAttrs } }) {
return <PreviewView node={node} vars={vars} />;
}
: 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<string, string> = {
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<AnexaTemplateMeta[]>('/admin/anexa-templates').then((r) => r.data),
staleTime: 60_000,
});
if (isLoading) {
return <Center h={200}><Loader color={teal} /></Center>;
}
return (
<Box>
<Box mb={24}>
<Title order={2} style={{ fontFamily: font, color: '#58595b' }}>Șabloane Anexa</Title>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 4 }} />
<Text c="dimmed" mt={8} style={{ fontFamily: font, fontSize: '0.875rem' }}>
Editați structura și conținutul documentelor DOCX generate automat.
</Text>
</Box>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 2 }} spacing={16}>
{(data ?? []).map((t) => (
<Card
key={t.type}
shadow="sm"
radius="md"
withBorder
onClick={() => navigate(`/admin/templates/${t.type}`)}
style={{ cursor: 'pointer', borderLeft: `4px solid ${teal}`, fontFamily: font }}
>
<Card.Section p={16} pb={8}>
<Box style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<IconFileText size={24} color={teal} />
<Badge color="medpark" variant="light" size="lg" style={{ fontFamily: font, fontSize: '0.8rem' }}>
{TYPE_LABELS[t.type]}
</Badge>
</Box>
<Text fw={600} mt={10} style={{ fontFamily: font }}>
{t.name}
</Text>
</Card.Section>
<Card.Section p={16} pt={0}>
<Text size="xs" c="dimmed" style={{ fontFamily: font }}>
Actualizat: {dayjs(t.updatedAt).format('DD.MM.YYYY HH:mm')}
</Text>
</Card.Section>
</Card>
))}
</SimpleGrid>
</Box>
);
}
```
---
## 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 (
<ScrollArea h="100%" p={12}>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Variabile disponibile
</Text>
<Stack gap={16}>
{VARIABLE_GROUPS.map((group) => (
<Box key={group.label}>
<Text size="xs" fw={600} mb={6} style={{ fontFamily: font, color: group.color }}>
{group.label}
</Text>
<Stack gap={4}>
{group.vars.map((v) => (
<Button
key={v.key}
size="xs"
variant="outline"
onClick={() => insertChip(v.key, v.label)}
style={{
fontFamily: font,
fontSize: '0.72rem',
borderColor: group.color,
color: group.color,
justifyContent: 'flex-start',
height: 28,
}}
>
{v.label}
</Button>
))}
</Stack>
</Box>
))}
</Stack>
</ScrollArea>
);
}
```
- [ ] **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<string, string> = {
ANEXA_3: 'Anexa 3',
ANEXA_4: 'Anexa 4',
ANEXA_4B: 'Anexa 4B',
ANEXA_6: 'Anexa 6',
};
function buildVars(emp: PreviewEmployee | null | undefined): Record<string, string> {
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<PreviewEmployee | null>(null);
const [previewKey, setPreviewKey] = useState(0); // bump to recreate preview editor
const [empPickerOpen, setEmpPickerOpen] = useState(false);
const [empSearch, setEmpSearch] = useState('');
const [pickedEmpId, setPickedEmpId] = useState<string | null>(null);
const { data: template, isLoading } = useQuery({
queryKey: ['admin', 'anexa-templates', type],
queryFn: () =>
apiClient.get<AnexaTemplate>(`/admin/anexa-templates/${type!}`).then((r) => r.data),
enabled: !!type,
});
const { data: versions } = useQuery({
queryKey: ['admin', 'anexa-template-versions', type],
queryFn: () =>
apiClient.get<AnexaTemplateVersion[]>(`/admin/anexa-templates/${type!}/versions`).then((r) => r.data),
enabled: showVersions && !!type,
});
const { data: previewEmp } = useQuery({
queryKey: ['admin', 'preview-employee'],
queryFn: () =>
apiClient.get<PreviewEmployee>('/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<AnexaTemplate>(`/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<PreviewEmployee>(`/admin/anexa-templates/preview-employee?employeeId=${found.id}`)
.then((r) => {
setPreviewEmployee(r.data);
setPreviewKey((k) => k + 1);
setEmpPickerOpen(false);
});
}
if (isLoading) return <Center h={200}><Loader color={teal} /></Center>;
return (
<Box style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
{/* Top bar */}
<Box style={{ background: teal, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 12, borderRadius: '8px 8px 0 0' }}>
<ActionIcon variant="transparent" onClick={() => navigate('/admin/templates')}>
<IconArrowLeft size={18} color="white" />
</ActionIcon>
<Text fw={700} c="white" style={{ fontFamily: font, fontSize: '0.9rem' }}>
{TYPE_LABELS[type ?? ''] ?? type}
</Text>
<Text c="rgba(255,255,255,0.6)" style={{ fontFamily: font, fontSize: '0.8rem' }}>
{template?.name}
</Text>
<Group ml="auto" gap={8}>
<Tooltip label="Versiuni">
<ActionIcon variant="subtle" c="white" onClick={() => setShowVersions((v) => !v)}>
<IconHistory size={18} />
</ActionIcon>
</Tooltip>
<Button
size="xs"
variant="white"
color="gray"
leftSection={<IconRefresh size={14} />}
onClick={() => leftEditor?.commands.setContent(template?.contentJson as never)}
style={{ fontFamily: font }}
>
Resetează
</Button>
<Button
size="xs"
leftSection={<IconDeviceFloppy size={14} />}
loading={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
style={{ background: '#fbb034', color: '#333', fontFamily: font, fontWeight: 600 }}
>
Salvează
</Button>
</Group>
</Box>
{/* Formatting toolbar */}
{leftEditor && (
<Box style={{ background: '#f8f9fa', borderBottom: '1px solid #e9ecef', padding: '6px 12px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<Button size="xs" variant={leftEditor.isActive('bold') ? 'filled' : 'default'} color="medpark" fw={700} onClick={() => leftEditor.chain().focus().toggleBold().run()} style={{ fontFamily: font, minWidth: 32 }}>B</Button>
<Button size="xs" variant={leftEditor.isActive('italic') ? 'filled' : 'default'} color="medpark" fs="italic" onClick={() => leftEditor.chain().focus().toggleItalic().run()} style={{ fontFamily: font, minWidth: 32 }}>I</Button>
<Button size="xs" variant={leftEditor.isActive('underline') ? 'filled' : 'default'} color="medpark" onClick={() => leftEditor.chain().focus().toggleUnderline().run()} style={{ fontFamily: font, minWidth: 32, textDecoration: 'underline' }}>U</Button>
<Divider orientation="vertical" />
<Button size="xs" variant="default" onClick={() => leftEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} style={{ fontFamily: font }}> Tabel</Button>
<Divider orientation="vertical" />
<Text size="xs" c="dimmed" style={{ fontFamily: font }}>Inserează:</Text>
{VARIABLE_GROUPS.slice(0, 3).map((g) => (
<Button
key={g.label}
size="xs"
style={{ borderRadius: 12, borderColor: g.color, color: g.color, fontFamily: font, fontSize: '0.72rem' }}
variant="outline"
onClick={() => {
const first = g.vars[0];
leftEditor.chain().focus().insertContent({ type: 'variableChip', attrs: { key: first.key, label: first.label } }).run();
}}
>
+ {g.label}
</Button>
))}
</Box>
)}
{/* Split body */}
<Box style={{ display: 'grid', gridTemplateColumns: showVersions ? '1fr 1fr 280px' : '1fr 1fr 220px', flex: 1, overflow: 'hidden', border: '1px solid #e9ecef', borderTop: 'none' }}>
{/* Left: TipTap editor */}
<Box style={{ borderRight: '1px solid #e9ecef', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box style={{ flex: 1, overflowY: 'auto', padding: 16, background: 'white', fontFamily: 'Times New Roman, serif', fontSize: '0.85rem', lineHeight: 1.8 }}>
{leftEditor && <EditorContent editor={leftEditor} />}
</Box>
</Box>
{/* Right: Preview */}
<Box style={{ borderRight: '1px solid #e9ecef', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box style={{ background: '#e6f4f4', padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid #b3dada' }}>
<Text size="xs" fw={600} c={teal} style={{ fontFamily: font }}>👁 Preview</Text>
<Button size="xs" variant="outline" color="medpark" ml="auto" onClick={() => setEmpPickerOpen(true)} style={{ fontFamily: font, fontSize: '0.7rem', height: 24 }}>
Schimbă angajatul test
</Button>
</Box>
<Box style={{ flex: 1, overflowY: 'auto', padding: 16, background: 'white', fontFamily: 'Times New Roman, serif', fontSize: '0.85rem', lineHeight: 1.8 }}>
{previewEditor && <EditorContent editor={previewEditor} />}
</Box>
</Box>
{/* Variable sidebar or version history */}
<Box style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', background: '#fafafa' }}>
{showVersions ? (
<ScrollArea h="100%" p={12}>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ fontFamily: font, textTransform: 'uppercase' }}>Versiuni</Text>
{(versions ?? []).map((v) => (
<Paper key={v.id} shadow="xs" p={10} mb={8} radius="sm">
<Text size="xs" fw={500} style={{ fontFamily: font }}>{dayjs(v.savedAt).format('DD.MM.YYYY HH:mm')}</Text>
{v.label && <Text size="xs" c="dimmed" style={{ fontFamily: font }}>{v.label}</Text>}
<Button size="xs" variant="subtle" color="medpark" mt={6} loading={restoreMutation.isPending}
onClick={() => restoreMutation.mutate(v.id)} style={{ fontFamily: font, fontSize: '0.7rem' }}>
Restaurează
</Button>
</Paper>
))}
{(versions ?? []).length === 0 && (
<Text size="xs" c="dimmed" style={{ fontFamily: font }}>Fără versiuni salvate.</Text>
)}
</ScrollArea>
) : (
leftEditor && <VariableSidebar editor={leftEditor} />
)}
</Box>
</Box>
{/* Change preview employee modal */}
<Modal opened={empPickerOpen} onClose={() => setEmpPickerOpen(false)} title={<Text fw={600} style={{ fontFamily: font }}>Schimbă angajatul test</Text>} size="sm">
<Select
label="Caută angajat"
placeholder="min. 2 caractere..."
searchable
data={(empSearchResults?.items ?? []).map((e) => ({ value: e.id, label: `${e.nume} ${e.prenume}${e.idnp}` }))}
value={pickedEmpId}
onChange={setPickedEmpId}
onSearchChange={setEmpSearch}
searchValue={empSearch}
nothingFoundMessage={empSearch.length < 2 ? 'Introdu cel puțin 2 caractere' : 'Niciun rezultat'}
styles={{ label: { fontFamily: font, fontWeight: 500 } }}
/>
<Group justify="flex-end" mt={16}>
<Button variant="subtle" onClick={() => setEmpPickerOpen(false)} style={{ fontFamily: font }}>Anulează</Button>
<Button disabled={!pickedEmpId} onClick={changePreviewEmployee} style={{ background: teal, fontFamily: font }}>Aplică</Button>
</Group>
</Modal>
</Box>
);
}
```
- [ ] **Step 3: Type-check**
```bash
pnpm --filter web typecheck
```
Fix any type errors (most likely around TipTap table sub-extensions import paths — use `@tiptap/extension-table` if TableRow/TableCell/TableHeader are exported from there directly, or use `Table`, `TableRow`, `TableCell`, `TableHeader` from the same package).
---
## Task 15: Wire Anexa editor into App.tsx
**Files:**
- Modify: `apps/web/src/App.tsx`
- [ ] **Step 1: Add imports and routes**
Add imports at the top:
```typescript
import { IconTemplate } from '@tabler/icons-react';
import { TemplatesListPage } from './pages/admin/templates/TemplatesListPage';
import { TemplateEditorPage } from './pages/admin/templates/TemplateEditorPage';
```
Add nav item to `NAV_ITEMS` (at the end, after `nav.inbox`):
```typescript
{ labelKey: 'nav.admin_templates', path: '/admin/templates', icon: <IconTemplate size={20} stroke={1.6} color="#7c3aed" /> },
```
Add routes (inside `<Routes>` after the medic-inbox route):
```tsx
<Route path="/admin/templates" element={<TemplatesListPage />} />
<Route path="/admin/templates/:type" element={<TemplateEditorPage />} />
```
- [ ] **Step 2: Test the full flow manually**
```bash
pnpm dev
```
1. Log in as `hr_admin`
2. Click "Șabloane Anexa" in nav → should show 4 cards (after seed ran)
3. Click "Anexa 3" card → should open the two-panel editor
4. Type something → should appear in the left panel
5. Click "Salvează" → notification "Șablonul a fost salvat."
6. Click "Versiuni" icon → right panel shows 1 version
7. Click a variable from the sidebar → chip appears in left editor
8. Chip should appear in preview panel as resolved value
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/pages/admin/ apps/web/src/App.tsx apps/web/src/api/types.ts
git commit -m "feat: Anexa template editor — TipTap two-panel editor with variable chips and live preview"
```
---
## Task 16: tiptap-to-docx converter
This converter replaces the hardcoded document generation in `document-generator.service.ts`. It walks TipTap JSON and builds `docx` library objects.
**Files:**
- Create: `apps/api/src/modules/medical/services/tiptap-to-docx.ts`
- [ ] **Step 1: Create the converter**
```typescript
// apps/api/src/modules/medical/services/tiptap-to-docx.ts
import {
Paragraph, TextRun, Table, TableRow, TableCell,
HeadingLevel, AlignmentType, WidthType, BorderStyle,
ITableBordersOptions,
} from 'docx';
type TiptapMark = { type: string };
type TiptapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TiptapNode[];
text?: string;
marks?: TiptapMark[];
};
export type TemplateVars = Record<string, string>;
const THIN_BORDER: ITableBordersOptions = {
top: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
left: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
right: { style: BorderStyle.SINGLE, size: 1, color: '999999' },
};
/**
* Convert a TipTap JSON document to an array of docx block-level children.
* Pass the resolved `vars` map (key → string value) for variableChip substitution.
*/
export function tiptapToDocx(doc: TiptapNode, vars: TemplateVars): (Paragraph | Table)[] {
return (doc.content ?? []).flatMap((node) => convertBlock(node, vars));
}
function convertBlock(node: TiptapNode, vars: TemplateVars): (Paragraph | Table)[] {
switch (node.type) {
case 'paragraph':
return [new Paragraph({
alignment: resolveAlign(node.attrs?.textAlign as string | undefined),
children: inlineRuns(node.content ?? [], vars),
})];
case 'heading':
return [new Paragraph({
heading: resolveHeading(node.attrs?.level as number | undefined),
alignment: resolveAlign(node.attrs?.textAlign as string | undefined),
children: inlineRuns(node.content ?? [], vars),
})];
case 'bulletList':
case 'orderedList':
return (node.content ?? []).flatMap((item) =>
(item.content ?? []).flatMap((p) => convertBlock(p, vars)),
);
case 'table':
return [buildTable(node, vars)];
default:
return [];
}
}
function inlineRuns(nodes: TiptapNode[], vars: TemplateVars): TextRun[] {
return nodes.flatMap((node) => {
if (node.type === 'text') {
const bold = node.marks?.some((m) => m.type === 'bold') ?? false;
const italics = node.marks?.some((m) => m.type === 'italic') ?? false;
const underline = node.marks?.some((m) => m.type === 'underline') ? {} : undefined;
return [new TextRun({ text: node.text ?? '', bold, italics, underline })];
}
if (node.type === 'variableChip') {
const key = node.attrs?.key as string;
const value = vars[key] ?? `[${key}]`;
return [new TextRun({ text: value, bold: true })];
}
if (node.type === 'hardBreak') {
return [new TextRun({ break: 1 })];
}
return [];
});
}
function buildTable(node: TiptapNode, vars: TemplateVars): Table {
const rows = (node.content ?? []).map((rowNode) =>
new TableRow({
children: (rowNode.content ?? []).map((cellNode) => {
const paragraphs = (cellNode.content ?? []).flatMap((p) => convertBlock(p, vars));
return new TableCell({
children: paragraphs.length > 0
? (paragraphs as Paragraph[])
: [new Paragraph({ children: [] })],
borders: THIN_BORDER,
});
}),
}),
);
return new Table({
rows,
width: { size: 100, type: WidthType.PERCENTAGE },
});
}
function resolveAlign(align?: string): AlignmentType {
if (align === 'center') return AlignmentType.CENTER;
if (align === 'right') return AlignmentType.RIGHT;
if (align === 'justify') return AlignmentType.JUSTIFIED;
return AlignmentType.LEFT;
}
function resolveHeading(level?: number): HeadingLevel {
const map: Record<number, HeadingLevel> = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
};
return map[level ?? 1] ?? HeadingLevel.HEADING_1;
}
```
- [ ] **Step 2: Type-check**
```bash
pnpm --filter api typecheck
```
Expected: no errors.
---
## Task 17: Wire tiptap-to-docx into document-generator.service.ts
This is the integration step. The existing `document-generator.service.ts` currently builds DOCX nodes programmatically. After this change, it loads the `AnexaTemplate` and passes TipTap JSON through the converter.
**Files:**
- Modify: `apps/api/src/modules/medical/services/document-generator.service.ts`
> ⚠️ **Risk note:** This changes existing DOCX generation behavior. Test DOCX output for all 4 Anexa types manually after completing this task. If output is wrong, revert and adjust seed template content in Task 9.
- [ ] **Step 1: Read the current document-generator.service.ts**
```bash
# Read the file to understand its current structure before modifying
cat apps/api/src/modules/medical/services/document-generator.service.ts
```
- [ ] **Step 2: Add tiptap-to-docx import and template loading**
In `document-generator.service.ts`, add these imports at the top:
```typescript
import { AnexaType } from '@prisma/client';
import { tiptapToDocx, TemplateVars } from './tiptap-to-docx';
```
Add a private helper to build the `vars` map from employee + checkup data (replace placeholders with actual field access matching the current service's data model):
```typescript
private buildVars(employee: { idnp: string; nume: string; prenume: string; dataNasterii: Date | string; contracts?: { department?: { name: string }; functiaClasificator?: string | null }[]; medicalProfile?: { ocupatieCorm?: string | null; dozaCumulataExternaMsv?: string | null; dozaCumulataInternaMsv?: string | null } | null }): TemplateVars {
const contract = employee.contracts?.[0];
const profile = employee.medicalProfile;
const dob = employee.dataNasterii instanceof Date
? employee.dataNasterii
: new Date(employee.dataNasterii);
return {
'company.name': 'Medpark International Hospital',
'company.idno': '1003600035476',
'company.address': 'mun. Chișinău, str. Păcii 1',
'document.date': new Date().toLocaleDateString('ro-MD', { day: '2-digit', month: '2-digit', year: 'numeric' }),
'document.number': '001',
'employee.lastName': employee.nume,
'employee.firstName': employee.prenume,
'employee.idnp': employee.idnp,
'employee.birthYear': String(dob.getFullYear()),
'employee.occupation': profile?.ocupatieCorm ?? contract?.functiaClasificator ?? '—',
'employee.department': contract?.department?.name ?? '—',
'row.index': '1',
'row.seatNumber': '1',
'row.employeeName': `${employee.prenume} ${employee.nume}`,
'row.riskFactors': '—',
'radiation.externalMsv': profile?.dozaCumulataExternaMsv?.toString() ?? '0.00',
'radiation.internalMsv': profile?.dozaCumulataInternaMsv?.toString() ?? '0.00',
};
}
private async loadTemplate(type: AnexaType) {
const template = await this.prisma.anexaTemplate.findUnique({ where: { type } });
if (!template) throw new Error(`Șablonul ${type} nu a fost seeded. Rulați pnpm db:seed.`);
return template.contentJson as { type: string; content: unknown[] };
}
```
- [ ] **Step 3: Replace one Anexa generation method to use the converter**
Find the method that generates Anexa 3 (usually named something like `generateAnexa3` or called in a `switch`). Replace its body with:
```typescript
// Example for generateAnexa3 — adapt method name/signature to match actual code
async generateAnexa3(employee: EmployeeForDoc): Promise<Buffer> {
const template = await this.loadTemplate('ANEXA_3');
const vars = this.buildVars(employee);
const children = tiptapToDocx(template as never, vars);
const doc = new Document({ sections: [{ children }] });
return Packer.toBuffer(doc);
}
```
Repeat for Anexa 4, 4B, and 6 using `'ANEXA_4'`, `'ANEXA_4B'`, `'ANEXA_6'`.
- [ ] **Step 4: Type-check**
```bash
pnpm --filter api typecheck
```
- [ ] **Step 5: Manual smoke test**
1. Go to `/medical` in the browser
2. Select some employees and click "Generează documente"
3. Download the generated DOCX files and verify they contain the expected content
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/modules/medical/services/
git commit -m "feat: wire tiptap-to-docx converter into document-generator — DOCX now uses DB templates"
```
---
## Self-Review Checklist
**Spec coverage:**
- [x] §1 Contracts UI: global page with table, filters, status badges → Tasks 26
- [x] §1 `GET /api/v1/contracts` endpoint with departmentId/perioada/status/search → Task 23
- [x] §1 ContractDrawer reused for row-click and "Adaugă" flow → Task 5
- [x] §1 Nav item `hr_admin`, `hr_specialist` → Task 6
- [x] §2 Seed data: already in seed.ts, run via Task 1
- [x] §3 AnexaTemplate + AnexaTemplateVersion Prisma models → Task 7
- [x] §3 All 6 API endpoints → Task 8
- [x] §3 TipTap editor with VariableChip extension → Tasks 1011
- [x] §3 Live preview panel (second read-only editor) → Task 14
- [x] §3 Variable sidebar → Task 14
- [x] §3 Version history UI → Task 14
- [x] §3 tiptap-to-docx converter → Task 16
- [x] §3 Seed templates → Task 9
- [x] §3 DOCX generator integration → Task 17
**Gaps identified:**
- The "max vacation days" badge on drawer header (spec §1 business rule) is not covered — add it after initial delivery as it requires a separate query.
- `preview-employee` endpoint currently returns the most recent employee, not a per-id lookup; `changePreviewEmployee` in Task 14 uses `?employeeId=` query param which is not implemented in the controller. Fix: add `@Query('employeeId') employeeId?: string` param in `AnexaTemplatesController.getPreviewEmployee()` and pass to service.
**Type consistency:** All interfaces added in Task 4 and Task 12 are referenced in Tasks 5, 8, 13, 14 under the same names (`ContractListItem`, `AnexaTemplate`, `PreviewEmployee`). Consistent throughout.