- 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>
72 KiB
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.tsapps/api/src/modules/contracts/contracts-global.service.tsapps/api/src/modules/contracts/contracts-global.module.tsapps/api/src/modules/admin/anexa-templates/anexa-templates.controller.tsapps/api/src/modules/admin/anexa-templates/anexa-templates.service.tsapps/api/src/modules/admin/anexa-templates/dto/update-template.dto.tsapps/api/src/modules/admin/admin.module.tsapps/web/src/pages/contracts/ContractsPage.tsxapps/web/src/pages/admin/templates/TemplatesListPage.tsxapps/web/src/pages/admin/templates/TemplateEditorPage.tsxapps/web/src/pages/admin/templates/extensions/VariableChipExtension.tsxapps/web/src/pages/admin/templates/components/VariableSidebar.tsxapps/api/src/modules/medical/services/tiptap-to-docx.ts
Modified:
apps/api/prisma/schema.prisma— addAnexaTypeenum + 2 modelsapps/api/prisma/seed.ts— addAnexaTemplateseed recordsapps/api/src/app.module.ts— import 2 new modulesapps/api/src/modules/employees/drawers/ContractDrawer.tsx— invalidate['contracts']on successapps/api/src/modules/medical/services/document-generator.service.ts— wire tiptap-to-docxapps/web/src/App.tsx— add 2 nav items + 3 routesapps/web/src/api/types.ts— addContractListItem,AnexaTemplate,AnexaTemplateVersionapps/web/src/i18n/ro.json— addnav.contracts,nav.admin_templateskeys
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
epnpm db:sed
Expected output:
🌱 Seeding reference data...
✓ DisabilityGrade (3)
✓ TaxExemption (7)
✓ WorkSchedule (7)
✓ Department (42)
✅ Seed complete.
- Step 2: Verify in Prisma Studio
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
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
// 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
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
// 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
// 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:
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
pnpm --filter api typecheck
pnpm api:dev
- Step 5: Smoke-test the endpoint
# 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
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:
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:
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
pnpm --filter web typecheck
Task 5: ContractsPage frontend
Files:
-
Create:
apps/web/src/pages/contracts/ContractsPage.tsx -
Step 1: Create the page
// 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
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:
import { IconFileDescription } from '@tabler/icons-react';
import { ContractsPage } from './pages/contracts/ContractsPage';
In the NAV_ITEMS array, add after the nav.departments entry:
{ labelKey: 'nav.contracts', path: '/contracts', icon: <IconFileDescription size={20} stroke={1.6} color="#008286" /> },
In the <Routes> section, add after the departments route:
<Route path="/contracts" element={<ContractsPage />} />
- Step 2: Add i18n keys
Find apps/web/src/i18n/ro.json and add to the nav section:
"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
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
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:
enum AnexaType {
ANEXA_3
ANEXA_4
ANEXA_4B
ANEXA_6
}
At the end of the file (after the AuditLog model), add:
// ═══════════════════════════════════════════════════════════════
// 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
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
pnpm db:studio
Tables anexa_templates and anexa_template_versions should appear. Both empty.
- Step 4: Commit
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
// 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
// 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
// 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
// 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:
import { AdminModule } from './modules/admin/admin.module';
// ...
@Module({
imports: [
// ... existing ...
ContractsGlobalModule,
AdminModule, // ADD THIS
],
})
- Step 6: Type-check
pnpm --filter api typecheck
Expected: no errors.
- Step 7: Commit
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:
// ── 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
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
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
pnpm --filter web add @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-table @tiptap/extension-underline
- Step 2: Verify import resolves
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
// 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
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
// ─── 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
// 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
// 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
// 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
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:
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):
{ 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):
<Route path="/admin/templates" element={<TemplatesListPage />} />
<Route path="/admin/templates/:type" element={<TemplateEditorPage />} />
- Step 2: Test the full flow manually
pnpm dev
- Log in as
hr_admin - Click "Șabloane Anexa" in nav → should show 4 cards (after seed ran)
- Click "Anexa 3" card → should open the two-panel editor
- Type something → should appear in the left panel
- Click "Salvează" → notification "Șablonul a fost salvat."
- Click "Versiuni" icon → right panel shows 1 version
- Click a variable from the sidebar → chip appears in left editor
- Chip should appear in preview panel as resolved value
- Step 3: Commit
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
// 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
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
# 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:
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):
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:
// 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
pnpm --filter api typecheck
- Step 5: Manual smoke test
- Go to
/medicalin the browser - Select some employees and click "Generează documente"
- Download the generated DOCX files and verify they contain the expected content
- Step 6: Commit
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:
- §1 Contracts UI: global page with table, filters, status badges → Tasks 2–6
- §1
GET /api/v1/contractsendpoint with departmentId/perioada/status/search → Task 2–3 - §1 ContractDrawer reused for row-click and "Adaugă" flow → Task 5
- §1 Nav item
hr_admin,hr_specialist→ Task 6 - §2 Seed data: already in seed.ts, run via Task 1
- §3 AnexaTemplate + AnexaTemplateVersion Prisma models → Task 7
- §3 All 6 API endpoints → Task 8
- §3 TipTap editor with VariableChip extension → Tasks 10–11
- §3 Live preview panel (second read-only editor) → Task 14
- §3 Variable sidebar → Task 14
- §3 Version history UI → Task 14
- §3 tiptap-to-docx converter → Task 16
- §3 Seed templates → Task 9
- §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-employeeendpoint currently returns the most recent employee, not a per-id lookup;changePreviewEmployeein Task 14 uses?employeeId=query param which is not implemented in the controller. Fix: add@Query('employeeId') employeeId?: stringparam inAnexaTemplatesController.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.