33800292aa
- 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>
2195 lines
72 KiB
Markdown
2195 lines
72 KiB
Markdown
# 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 2–6
|
||
- [x] §1 `GET /api/v1/contracts` endpoint with departmentId/perioada/status/search → Task 2–3
|
||
- [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 10–11
|
||
- [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.
|