chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)

- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Danil Suhomlinov
2026-06-08 17:42:45 +03:00
commit 33800292aa
186 changed files with 30437 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
# Build context = monorepo root (hrm-medpark/)
FROM node:20-bookworm-slim AS build
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable
WORKDIR /repo
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile
COPY apps/web apps/web
RUN pnpm --filter web build
# ---- nginx serves the static build and proxies /api to the api container ----
FROM nginx:alpine AS runtime
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HRM Medpark</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API requests are proxied to the NestJS container (same origin → no CORS, no build-time URL)
location /api/ {
proxy_pass http://api:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "web",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^3.3.4",
"@mantine/core": "^7.6.2",
"@mantine/dates": "^7.6.2",
"@mantine/form": "^7.6.2",
"@mantine/hooks": "^7.6.2",
"@mantine/modals": "^7.6.2",
"@mantine/notifications": "^7.6.2",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.28.6",
"@tiptap/core": "^3.23.1",
"@tiptap/extension-table": "^3.23.1",
"@tiptap/extension-underline": "^3.23.1",
"@tiptap/pm": "^3.23.1",
"@tiptap/react": "^3.23.1",
"@tiptap/starter-kit": "^3.23.1",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^8.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"react-i18next": "^14.1.0",
"react-router-dom": "^6.22.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@vitejs/plugin-react": "^4.2.1",
"postcss": "^8.4.38",
"postcss-preset-mantine": "^1.13.0",
"typescript": "^5.4.2",
"vite": "^5.2.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

+237
View File
@@ -0,0 +1,237 @@
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AppShell, Group, Text, Stack, UnstyledButton, Menu, Avatar, Badge } from '@mantine/core';
import {
IconLayoutDashboard, IconUsers, IconSitemap,
IconTargetArrow, IconShieldExclamation, IconStethoscope, IconMailbox,
IconFileDescription, IconBox,
} from '@tabler/icons-react';
import { EmployeesPage } from './pages/employees/EmployeesPage';
import { EmployeeDetailPage } from './pages/employees/EmployeeDetailPage';
import { DepartmentsPage } from './pages/departments/DepartmentsPage';
import { EvaluationPage } from './pages/evaluation/EvaluationPage';
import { CampaignDetailPage } from './pages/evaluation/CampaignDetailPage';
import { EvaluationFormPage } from './pages/evaluation/EvaluationFormPage';
import { RiskCardsPage } from './pages/medical/RiskCardsPage';
import { MedicalControlPage } from './pages/medical/MedicalControlPage';
import { MedicalInboxPage } from './pages/medical/MedicalInboxPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { LoginPage } from './pages/auth/LoginPage';
import { ContractsPage } from './pages/contracts/ContractsPage';
import { InventoryPage } from './pages/inventory/InventoryPage';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
// ── Navigation items ──────────────────────────────────────
interface NavItem { labelKey: string; path: string; icon: React.ReactNode; roles?: string[] }
// roles: undefined = all authenticated users; defined = only those roles
const NAV_ITEMS: NavItem[] = [
{ labelKey: 'nav.dashboard', path: '/dashboard', icon: <IconLayoutDashboard size={20} stroke={1.6} color="#008286" /> },
{ labelKey: 'nav.employees', path: '/employees', icon: <IconUsers size={20} stroke={1.6} color="#7c3aed" />, roles: ['hr_admin', 'hr_specialist', 'manager', 'nursing_director'] },
{ labelKey: 'nav.departments', path: '/departments', icon: <IconSitemap size={20} stroke={1.6} color="#1e3a8a" />, roles: ['hr_admin', 'hr_specialist'] },
{ labelKey: 'nav.contracts', path: '/contracts', icon: <IconFileDescription size={20} stroke={1.6} color="#008286" />, roles: ['hr_admin', 'hr_specialist'] },
{ labelKey: 'nav.inventory', path: '/inventory', icon: <IconBox size={20} stroke={1.6} color="#008286" />, roles: ['hr_admin', 'hr_specialist'] },
{ labelKey: 'nav.evaluation', path: '/evaluation', icon: <IconTargetArrow size={20} stroke={1.6} color="#e53e3e" />, roles: ['hr_admin', 'hr_specialist', 'manager', 'nursing_director', 'quality_auditor'] },
{ labelKey: 'nav.risk_cards', path: '/risk-cards', icon: <IconShieldExclamation size={20} stroke={1.6} color="#f59e0b" />, roles: ['hr_admin', 'hr_specialist', 'manager', 'medic_familie'] },
{ labelKey: 'nav.medical', path: '/medical', icon: <IconStethoscope size={20} stroke={1.6} color="#0891b2" />, roles: ['hr_admin', 'hr_specialist', 'manager'] },
{ labelKey: 'nav.inbox', path: '/medic-inbox', icon: <IconMailbox size={20} stroke={1.6} color="#4338ca" />, roles: ['hr_admin', 'medic_familie'] },
];
function NavLink({ item }: { item: NavItem }) {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const active = location.pathname === item.path ||
(item.path !== '/dashboard' && location.pathname.startsWith(item.path + '/'));
return (
<UnstyledButton
className="hrm-nav-link"
onClick={() => navigate(item.path)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 18px 12px 15px',
borderRadius: 6,
borderLeft: active ? `3px solid ${teal}` : '3px solid transparent',
background: active ? '#e6f4f4' : 'transparent',
color: active ? teal : '#58595b',
fontFamily: font,
fontWeight: active ? 600 : 400,
fontSize: '0.9375rem',
width: '100%',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
if (!active) (e.currentTarget as HTMLElement).style.background = '#f8f9fa';
}}
onMouseLeave={(e) => {
if (!active) (e.currentTarget as HTMLElement).style.background = 'transparent';
}}
>
{item.icon}
{t(item.labelKey)}
</UnstyledButton>
);
}
// ── Shell ─────────────────────────────────────────────────
function ProtectedRoute({ roles, children }: { roles?: string[]; children: React.ReactNode }) {
const role = localStorage.getItem('kc_role') ?? '';
if (roles && !roles.includes(role)) return <Navigate to="/dashboard" replace />;
return <>{children}</>;
}
function RouteFade({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<div key={location.pathname} className="hrm-page" style={{ padding: 40, minHeight: '100%' }}>
{children}
</div>
);
}
function Shell({ onLogout }: { onLogout: () => void }) {
const username = localStorage.getItem('kc_username') ?? 'HR Admin';
const role = localStorage.getItem('kc_role') ?? 'hr_admin';
const visibleNav = NAV_ITEMS.filter(item => !item.roles || item.roles.includes(role));
const initials = username
.split(' ')
.map((w) => w[0]?.toUpperCase() ?? '')
.slice(0, 2)
.join('');
return (
<AppShell
header={{ height: 72 }}
navbar={{ width: 280, breakpoint: 'sm' }}
padding={0}
styles={{
header: { background: '#ffffff', borderBottom: `3px solid ${teal}`, boxShadow: 'none' },
navbar: { background: '#ffffff', borderRight: '1px solid #e9ecef' },
main: { background: '#f8f9fa' },
}}
>
{/* ── HEADER ── */}
<AppShell.Header>
<Group h="100%" px={24} justify="space-between">
<img
src="/logo-medpark.png"
alt="Medpark International Hospital"
style={{ height: 44, objectFit: 'contain' }}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = 'none';
}}
/>
<Group gap={12}>
<Badge
size="sm"
style={{ background: teal + '18', color: teal, fontFamily: font, fontWeight: 600, border: `1px solid ${teal}33` }}
>
{role}
</Badge>
<Menu shadow="sm" width={200}>
<Menu.Target>
<UnstyledButton style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar size={36} color="medpark" radius="xl">
{initials || 'HR'}
</Avatar>
<Text size="sm" fw={500} c="#58595b" style={{ fontFamily: font, fontSize: '0.9375rem' }}>
{username}
</Text>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label style={{ fontFamily: font }}>{role}</Menu.Label>
<Menu.Divider />
<Menu.Item
color="red"
style={{ fontFamily: font }}
onClick={onLogout}
>
Ieșire
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
{/* ── NAVBAR ── */}
<AppShell.Navbar>
<Stack gap={4} p={14} pt={20}>
<Text
size="xs"
fw={700}
c="#adb5bd"
px={16}
pb={6}
style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
HRM
</Text>
{visibleNav.map((item) => (
<NavLink key={item.path} item={item} />
))}
</Stack>
</AppShell.Navbar>
{/* ── CONTENT ── */}
<AppShell.Main>
<RouteFade>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/employees" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','nursing_director']}><EmployeesPage /></ProtectedRoute>} />
<Route path="/employees/:id" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','nursing_director','medic_familie']}><EmployeeDetailPage /></ProtectedRoute>} />
<Route path="/departments" element={<ProtectedRoute roles={['hr_admin','hr_specialist']}><DepartmentsPage /></ProtectedRoute>} />
<Route path="/contracts" element={<ProtectedRoute roles={['hr_admin','hr_specialist']}><ContractsPage /></ProtectedRoute>} />
<Route path="/inventory" element={<ProtectedRoute roles={['hr_admin','hr_specialist']}><InventoryPage /></ProtectedRoute>} />
<Route path="/evaluation" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','nursing_director','quality_auditor']}><EvaluationPage /></ProtectedRoute>} />
<Route path="/evaluation/:id" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','nursing_director','quality_auditor']}><CampaignDetailPage /></ProtectedRoute>} />
<Route path="/evaluation/form/:id" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','nursing_director','quality_auditor']}><EvaluationFormPage /></ProtectedRoute>} />
<Route path="/risk-cards" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager','medic_familie']}><RiskCardsPage /></ProtectedRoute>} />
<Route path="/medical" element={<ProtectedRoute roles={['hr_admin','hr_specialist','manager']}><MedicalControlPage /></ProtectedRoute>} />
<Route path="/medic-inbox" element={<ProtectedRoute roles={['hr_admin','medic_familie']}><MedicalInboxPage /></ProtectedRoute>} />
</Routes>
</RouteFade>
</AppShell.Main>
</AppShell>
);
}
// ── Root ──────────────────────────────────────────────────
export default function App() {
const [authed, setAuthed] = useState(() => !!localStorage.getItem('kc_token'));
const handleLogin = () => setAuthed(true);
const handleLogout = () => {
localStorage.removeItem('kc_token');
localStorage.removeItem('kc_username');
localStorage.removeItem('kc_role');
setAuthed(false);
};
if (!authed) {
return <LoginPage onLogin={handleLogin} />;
}
return (
<BrowserRouter>
<Shell onLogout={handleLogout} />
</BrowserRouter>
);
}
+17
View File
@@ -0,0 +1,17 @@
import axios from 'axios';
export const apiClient = axios.create({
baseURL: '/api/v1',
});
// Прикрепляем Keycloak-токен из localStorage (Keycloak.js управляет им на клиенте)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('kc_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(r) => r,
(err) => Promise.reject(err),
);
+544
View File
@@ -0,0 +1,544 @@
// Shared TypeScript interfaces mirroring Prisma models as returned by the API.
// All dates are ISO strings from JSON.
export type Sex = 'F' | 'M';
export type MaritalStatus = 'casatorit' | 'necasatorit' | 'divortat' | 'vaduv';
export type EmployeeStatus = 'activ' | 'concediat' | 'suspendat';
export type DocumentType = 'buletin_de_identitate' | 'pasaport';
export type FamilyMemberType = 'contact_principal' | 'sot' | 'sotie' | 'mama' | 'tata' | 'copil';
export type StudyType = 'superioare' | 'medii_de_specialitate' | 'secundare_tehnice' | 'medii';
export type StudyLevel = 'de_baza' | 'postuniversitar';
export type PostUniversityType = 'masterat' | 'rezidentiat' | 'secundariat' | 'altele';
export type DiplomaStatus = 'confirmata' | 'neconfirmata';
export type QualificationCategory = 'fara' | 'cat_II' | 'cat_I' | 'superioara';
export type ScientificTitle = 'doctor' | 'doctor_habilitat';
export type TrainingType = 'orientare' | 'intern' | 'extern_RM' | 'extern_international';
export type DisciplinarySanctionType = 'avertisment' | 'mustrare' | 'mustrare_aspra';
export type ContractPeriod = 'determinata' | 'nedeterminata' | 'replasare_temporara';
export type ContractCategory = 'principal' | 'secundar';
export type ContractType = 'de_baza' | 'cumul';
export type SalaryType = 'fix' | 'pe_ore' | 'in_acord';
// ─── Reference entities ──────────────────────────────────────
export interface DisabilityGrade {
id: string;
code: string;
name: string;
}
export interface TaxExemption {
id: string;
code: string;
description: string;
}
export interface WorkSchedule {
id: string;
name: string;
daysWork: number;
daysRest: number;
hoursPerDay: number;
}
export interface Department {
id: string;
name: string;
code: string | null;
parentId: string | null;
children?: Department[];
parent?: Department | null;
}
// ─── Sub-entities ────────────────────────────────────────────
export interface IdentityDocument {
id: string;
employeeId: string;
tipAct: DocumentType;
seria: string | null;
nr: string;
dataEmiterii: string;
autoritateEmitenta: string;
dataExpirarii: string;
createdAt: string;
updatedAt: string;
}
export interface FamilyMember {
id: string;
employeeId: string;
tip: FamilyMemberType;
numePrenume: string;
dataNasterii: string | null;
idnp: string | null;
telefon: string | null;
tipScutireId: string | null;
tipScutire: TaxExemption | null;
createdAt: string;
updatedAt: string;
}
export interface Education {
id: string;
employeeId: string;
tipStudii: StudyType;
institutia: string;
specialitatea: string;
dataAbsolvirii: string | null;
nrSeriaDiploma: string | null;
dataEmiterii: string | null;
nrInregistrare: string | null;
confirmare: DiplomaStatus | null;
nivel: StudyLevel | null;
tipPostuniversitar: PostUniversityType | null;
createdAt: string;
updatedAt: string;
}
export interface Qualification {
id: string;
employeeId: string;
categorie: QualificationCategory;
dataObtinerii: string | null;
dataUltimeiConfirmari: string | null;
dataExpirarii: string | null;
specialitate: string | null;
createdAt: string;
updatedAt: string;
}
export interface Training {
id: string;
employeeId: string;
denumire: string;
inceput: string;
sfirsit: string | null;
tip: TrainingType;
tara: string | null;
nrOre: number | null;
organizatia: string | null;
certificat: boolean;
cost: string | null;
createdAt: string;
updatedAt: string;
}
export interface DisciplinarySanction {
id: string;
employeeId: string;
tip: DisciplinarySanctionType;
dataAplicarii: string;
dataExpirarii: string;
isStinsa: boolean;
createdAt: string;
updatedAt: string;
}
export interface Benefit {
id: string;
employeeId: string;
uniformaId: string | null;
uniforma: InventoryItem | null;
halatId: string | null;
halat: InventoryItem | null;
ciupiciId: string | null;
ciupici: InventoryItem | null;
vestaId: string | null;
vesta: InventoryItem | null;
ticheteMasa: boolean;
valoareTichet: string | null;
alimentatiePersonal: boolean;
abonamentTel: string | null;
aparatTelefonId: string | null;
aparatTelefon: InventoryItem | null;
cardCompanie: string | null;
automobilServiciu: string | null;
createdAt: string;
updatedAt: string;
}
export type InventoryItemType = 'uniforma' | 'halat' | 'ciupici' | 'vesta' | 'aparat_telefon' | 'alte';
export interface InventoryItem {
id: string;
sku: string;
name: string;
type: InventoryItemType;
size: string | null;
color: string | null;
pricePerUnit: string | null;
stockQty: number;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface PaginatedInventory {
total: number;
page: number;
limit: number;
items: InventoryItem[];
}
export interface CimServiceCategory {
id: string;
contractId: string;
categorieId: string;
tipRemunerare: 'tarif' | 'procent';
sumaNeta: string | null;
procent: string | null;
}
export interface EmploymentContract {
id: string;
nrCim: string;
employeeId: string;
categorie: ContractCategory;
dataSemnarii: string;
dataAngajarii: string;
dataDemisiei: string | null;
perioada: ContractPeriod;
dataTerminarii: string | null;
functiaClasificator: string | null;
codFunctie: string | null;
functiaOrganigrama: string | null;
tipCim: ContractType;
departmentId: string;
department: Department;
regimMunca: string | null;
tipSalarizare: SalaryType | null;
salarizareDetails: unknown;
clausaAditionala: unknown;
workScheduleId: string | null;
workSchedule: WorkSchedule | null;
categoriiServicii: CimServiceCategory[];
createdAt: string;
updatedAt: string;
}
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[];
}
// ─── Core Employee ────────────────────────────────────────────
export interface Employee {
id: string;
idnp: string;
nume: string;
prenume: string;
patronimic: string | null;
numeAnterior: string | null;
dataNasterii: string;
domiciliu: string;
adresaReala: string | null;
telefonPersonal: string;
telefonServiciu: string | null;
emailPersonal: string | null;
emailCorporativ: string | null;
sex: Sex;
codCpas: string | null;
stareCivila: MaritalStatus | null;
titluStiintific: ScientificTitle | null;
titluUniversitar: string | null;
status: EmployeeStatus;
gradDizabilitateId: string | null;
gradDizabilitate: DisabilityGrade | null;
recomandareInternaId: string | null;
recomandareInterna: Pick<Employee, 'id' | 'nume' | 'prenume' | 'idnp'> | null;
identityDocuments: IdentityDocument[];
familyMembers: FamilyMember[];
educations: Education[];
qualifications: Qualification[];
trainings: Training[];
disciplinarySanctions: DisciplinarySanction[];
benefit: Benefit | null;
contracts: EmploymentContract[];
createdAt: string;
updatedAt: string;
}
// ─── Paginated list response ──────────────────────────────────
export interface PaginatedEmployees {
total: number;
page: number;
limit: number;
items: EmployeeListItem[];
}
export interface EmployeeListItem {
id: string;
idnp: string;
nume: string;
prenume: string;
sex: Sex;
status: EmployeeStatus;
dataNasterii: string;
telefonPersonal: string;
emailCorporativ: string | null;
contracts: {
functiaOrganigrama: string | null;
department: { name: string };
}[];
}
// ─── Phase 5: Medical Control ─────────────────────────────────
export type MedicalCheckupType =
| 'la_angajare'
| 'periodic'
| 'la_reluarea_activitatii'
| 'la_incetarea_expunerii'
| 'suplimentar';
export type MedicalVerdict =
| 'apt'
| 'apt_perioada_adaptare'
| 'apt_conditionat'
| 'inapt_temporar'
| 'inapt';
export interface RiskFactors {
chimici?: string[];
fizici?: string[];
biologici?: string[];
ergonomici?: string[];
psihosociali?: string[];
}
export type RiskExposureType =
| 'AGENT_CHIMIC' | 'PULBERI' | 'AGENT_BIOLOGIC' | 'ZGOMOT'
| 'VIBRATII' | 'CAMP_ELECTROMAGNETIC' | 'RADIATII_OPTICE';
export interface RiskExposure {
id?: string;
tip: RiskExposureType;
denumire: string;
cas?: string | null;
einecs?: string | null;
clasificare?: string | null;
zonaAfectata?: string | null;
timpExpunere?: string | null;
vep?: string | null;
vlep?: string | null;
caracteristici?: string | null;
procesVerbal?: string | null;
}
export interface WorkplaceRiskCard {
id: string;
name: string;
riskFactors: RiskFactors | null;
// Antet Anexa 4
filiala?: string | null;
adresaFiliala?: string | null;
telefonFiliala?: string | null;
caemPrimeleDouaCifre?: string | null;
cormSubgrupaMajora?: string | null;
directiaSectiaSectorul?: string | null;
numarulLoculuiDeMunca?: string | null;
caemDiviziune?: string | null;
clasaConditiilorDeMunca?: string | null;
numarLucratoriPosibili?: number | null;
tipFisa?: string; // STANDARD | DISTANTA_DIGITAL
// Bloc descriptiv + subsol
evaluareDetalii?: Record<string, unknown> | null;
anexeIgienicoSanitare?: Record<string, unknown> | null;
mijloaceProtectieColectiva?: string | null;
mijloaceProtectieIndividuala?: string | null;
echipamentLucru?: string | null;
observatii?: string | null;
// Radiații ionizante (per loc de muncă)
radiatiiIonizante?: boolean | null;
radiatiiGrupa?: string | null;
radiatiiAparatura?: string | null;
radiatiiSurse?: string | null;
radiatiiTipExpunere?: string | null;
radiatiiMasuriProtectie?: string | null;
exposures?: RiskExposure[];
_count?: { profiles: number };
profiles?: {
id: string;
employee: { id: string; idnp: string; nume: string; prenume: string };
}[];
createdAt: string;
updatedAt: string;
}
export type OverexposureKind = 'EXCEPTIONALA' | 'ACCIDENTALA';
export interface RadiationOverexposure {
id?: string;
fel: OverexposureKind;
tipExpunere?: string | null;
data?: string | null;
dozaMsv?: number | string | null;
}
export interface EmployeeMedicalProfile {
id: string;
employeeId: string;
ocupatieCorm: string | null;
workplaceRiskCardId: string | null;
workplaceRiskCard: WorkplaceRiskCard | null;
dataUltimControlMedical: string | null;
expusRadiatiiIonizante: boolean;
dataIntrarii: string | null;
expunereAnterioaraPerioda: string | null;
expunereAnterioaraAni: number | null;
dozaCumulataExternaMsv: string | null;
dozaCumulataInternaMsv: string | null;
dozaTotalaMsv?: number;
overexposures?: RadiationOverexposure[];
createdAt: string;
updatedAt: string;
}
export interface GeneratedDoc {
name: string;
url: string;
type: string;
}
export interface MedicalCheckup {
id: string;
employeeId: string;
tip: MedicalCheckupType;
dataPlanificata: string;
dataEfectuata: string | null;
verdict: MedicalVerdict | null;
recomandari: string | null;
valabilPanaLa: string | null;
semnatDe: string | null;
documenteGenerate: GeneratedDoc[] | null;
employee?: Pick<Employee, 'id' | 'idnp' | 'nume' | 'prenume' | 'sex' | 'dataNasterii'> & {
medicalProfile?: EmployeeMedicalProfile | null;
};
createdAt: string;
updatedAt: string;
}
export interface UpcomingExpiration {
id: string;
employeeId: string;
dataUltimControlMedical: string | null;
expusRadiatiiIonizante: boolean;
employee: {
id: string;
idnp: string;
nume: string;
prenume: string;
contracts: { department: { name: string } }[];
};
workplaceRiskCard: { id: string; name: string } | null;
}
// ─── Dashboard ───────────────────────────────────────────────
export interface DashboardStats {
employees: { total: number; activ: number; concediat: number; suspendat: number };
activeContracts: number;
recentHires: number;
activeSanctions: number;
expirations: {
contractsDeterminata: Array<{
id: string;
nrCim: string;
dataTerminarii: string;
employee: { id: string; nume: string; prenume: string; idnp: string };
department: { name: string };
}>;
expiringDocs: Array<{
id: string;
tipAct: string;
dataExpirarii: string;
employee: { id: string; nume: string; prenume: string; idnp: string };
}>;
upcomingCheckups: Array<{
id: string;
tip: string;
dataPlanificata: string;
employee: { id: string; nume: string; prenume: string; idnp: string };
}>;
expiringQualifications: Array<{
id: string;
categorie: string;
dataExpirarii: string;
employee: { id: string; nume: string; prenume: string; idnp: string };
}>;
};
}
export interface BulkInitiateResult {
batchId: string;
groupsCount: number;
employeesCount: number;
checkups: {
employeeId: string;
checkupId: string;
documents: GeneratedDoc[];
}[];
}
export interface BulkDocumentContext {
telefon?: string;
fax?: string;
email?: string;
solicitant?: string;
functia?: string;
}
// ─── 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;
}
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;
}
+39
View File
@@ -0,0 +1,39 @@
{
"nav": {
"employees": "Employees",
"departments": "Departments",
"contracts": "Contracts",
"evaluation": "Evaluation",
"medical": "Medical control"
},
"employees": {
"title": "Employees",
"add": "Add employee",
"search": "Search by name, surname, IDNP...",
"columns": {
"idnp": "IDNP",
"name": "Name",
"position": "Position",
"department": "Department",
"status": "Status",
"phone": "Phone"
},
"status": {
"activ": "Active",
"concediat": "Dismissed",
"suspendat": "Suspended"
}
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"view": "View"
},
"validation": {
"idnp_invalid": "Invalid IDNP (13 digits, wrong check digit)",
"required": "Required field",
"email_invalid": "Invalid email address"
}
}
+14
View File
@@ -0,0 +1,14 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ro from './ro.json';
i18n
.use(initReactI18next)
.init({
resources: { ro: { translation: ro } },
lng: 'ro',
fallbackLng: 'ro',
interpolation: { escapeValue: false },
});
export default i18n;
+44
View File
@@ -0,0 +1,44 @@
{
"nav": {
"dashboard": "Dashboard",
"employees": "Angajați",
"departments": "Departamente",
"contracts": "Contracte",
"inventory": "Inventar",
"evaluation": "Evaluare",
"risk_cards": "Carduri de risc",
"medical": "Control medical",
"inbox": "Inbox medic"
},
"employees": {
"title": "Angajați",
"add": "Adaugă angajat",
"search": "Caută după nume, prenume, IDNP...",
"columns": {
"idnp": "IDNP",
"name": "Nume",
"position": "Funcție",
"department": "Departament",
"status": "Statut",
"phone": "Telefon"
},
"status": {
"all": "Toate statutele",
"activ": "Activ",
"concediat": "Concediat",
"suspendat": "Suspendat"
}
},
"actions": {
"save": "Salvează",
"cancel": "Anulează",
"edit": "Editează",
"delete": "Șterge",
"view": "Vizualizează"
},
"validation": {
"idnp_invalid": "IDNP invalid (13 cifre, cifra de control incorectă)",
"required": "Câmp obligatoriu",
"email_invalid": "Adresă email invalidă"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"nav": {
"employees": "Сотрудники",
"departments": "Отделы",
"contracts": "Контракты",
"evaluation": "Оценка",
"medical": "Медконтроль"
},
"employees": {
"title": "Сотрудники",
"add": "Добавить сотрудника",
"search": "Поиск по имени, фамилии, IDNP...",
"columns": {
"idnp": "IDNP",
"name": "Фамилия",
"position": "Должность",
"department": "Отдел",
"status": "Статус",
"phone": "Телефон"
},
"status": {
"activ": "Активен",
"concediat": "Уволен",
"suspendat": "Приостановлен"
}
},
"actions": {
"save": "Сохранить",
"cancel": "Отмена",
"edit": "Редактировать",
"delete": "Удалить",
"view": "Просмотр"
},
"validation": {
"idnp_invalid": "Некорректный IDNP (13 цифр, неверная контрольная цифра)",
"required": "Обязательное поле",
"email_invalid": "Некорректный email"
}
}
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { MantineProvider, createTheme } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import './styles/global.css';
import './i18n/i18n';
import App from './App';
// Medpark brand teal shades for Mantine (centered on #008286)
const medparkTeal: [string, string, string, string, string, string, string, string, string, string] = [
'#e6f4f4', // 0 — lightest
'#ccebeb',
'#99d7d8',
'#66c3c5',
'#33afb2',
'#009b9f',
'#008286', // 6 ← brand primary
'#006b6e',
'#005457',
'#003d3f', // 9 — darkest
];
const theme = createTheme({
fontFamily: "'Montserrat', Arial, sans-serif",
fontFamilyMonospace: "'Courier New', monospace",
primaryColor: 'medpark',
colors: { medpark: medparkTeal },
defaultRadius: 'sm',
fontSizes: {
xs: '0.8rem',
sm: '0.9375rem', // 15px — base for most UI text
md: '1.0625rem', // 17px
lg: '1.1875rem', // 19px
xl: '1.3125rem', // 21px
},
lineHeights: {
xs: '1.5',
sm: '1.55',
md: '1.6',
lg: '1.65',
xl: '1.7',
},
components: {
Button: {
defaultProps: { radius: 'sm', size: 'sm' },
styles: { root: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 } },
},
TextInput: {
defaultProps: { size: 'sm' },
styles: { input: { fontFamily: "'Montserrat', Arial, sans-serif" } },
},
Select: {
defaultProps: { size: 'sm' },
styles: { input: { fontFamily: "'Montserrat', Arial, sans-serif" } },
},
DateInput: {
defaultProps: { size: 'sm' },
},
Table: {
styles: { table: { fontFamily: "'Montserrat', Arial, sans-serif", fontSize: '0.9rem' } },
},
Badge: {
styles: { root: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 } },
},
Tabs: {
styles: { tab: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500, fontSize: '0.9rem' } },
},
Modal: {
defaultProps: { size: 'lg' },
},
Drawer: {
defaultProps: { size: 'xl' },
},
},
});
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={theme} defaultColorScheme="light">
<ModalsProvider>
<Notifications position="top-right" />
<App />
</ModalsProvider>
</MantineProvider>
</QueryClientProvider>
</React.StrictMode>,
);
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Box, Button, Select, Text, TextInput, Alert, Group, Stack } from '@mantine/core';
import { apiClient } from '../../api/client';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const ROLES = [
{ value: 'hr_admin', label: 'HR Admin' },
{ value: 'hr_specialist', label: 'HR Specialist' },
{ value: 'nursing_director', label: 'Nursing Director' },
{ value: 'quality_auditor', label: 'Quality Auditor' },
{ value: 'manager', label: 'Manager' },
{ value: 'medic_familie', label: 'Medic Familie' },
{ value: 'employee', label: 'Angajat' },
];
interface Props {
onLogin: () => void;
}
export function LoginPage({ onLogin }: Props) {
const [username, setUsername] = useState('admin');
const [role, setRole] = useState<string>('hr_admin');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setLoading(true);
setError(null);
try {
const res = await apiClient.post<{ token: string; username: string; role: string }>(
'/auth/dev-login',
{ username, role },
);
localStorage.setItem('kc_token', res.data.token);
localStorage.setItem('kc_username', res.data.username);
localStorage.setItem('kc_role', res.data.role);
onLogin();
} catch {
setError('Nu s-a putut autentifica. Verificați că API-ul rulează.');
} finally {
setLoading(false);
}
};
return (
<Box
style={{
minHeight: '100vh',
background: '#f8f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
style={{
background: '#fff',
border: '1px solid #e9ecef',
borderTop: `4px solid ${teal}`,
borderRadius: 10,
padding: '40px 48px',
width: 400,
boxShadow: '0 4px 24px rgba(0,0,0,0.07)',
}}
>
{/* Logo */}
<Box style={{ textAlign: 'center', marginBottom: 32 }}>
<img
src="/logo-medpark.png"
alt="Medpark International Hospital"
style={{ height: 44, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<Text
style={{
fontFamily: font,
fontWeight: 700,
fontSize: '1.1rem',
color: teal,
marginTop: 12,
}}
>
HRM Sistem de management HR
</Text>
<Text
style={{
fontFamily: font,
fontWeight: 300,
fontSize: '0.75rem',
color: '#adb5bd',
marginTop: 4,
}}
>
Mod dezvoltare autentificare locală
</Text>
</Box>
{error && (
<Alert color="red" mb={20} style={{ fontFamily: font, fontWeight: 300, fontSize: '0.85rem' }}>
{error}
</Alert>
)}
<Stack gap={16}>
<TextInput
label="Utilizator"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
placeholder="admin"
styles={{
label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' },
input: { fontFamily: font },
}}
/>
<Select
label="Rol"
value={role}
onChange={(v) => setRole(v ?? 'hr_admin')}
data={ROLES}
styles={{
label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' },
input: { fontFamily: font },
}}
/>
<Button
fullWidth
mt={8}
loading={loading}
onClick={() => void handleLogin()}
style={{
background: teal,
fontFamily: font,
fontWeight: 600,
fontSize: '0.9rem',
height: 42,
}}
>
Autentifică-te
</Button>
</Stack>
<Group justify="center" mt={24}>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.7rem', color: '#ced4da' }}>
În producție autentificarea se face prin Keycloak SSO
</Text>
</Group>
</Box>
</Box>
);
}
@@ -0,0 +1,278 @@
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 } 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);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedContract, setSelectedContract] = useState<ContractListItem | undefined>();
const [drawerEmployeeId, setDrawerEmployeeId] = useState('');
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>
<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>
)}
<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>
{drawerEmployeeId && (
<ContractDrawer
employeeId={drawerEmployeeId}
record={selectedContract}
opened={drawerOpen}
onClose={() => { setDrawerOpen(false); setSelectedContract(undefined); }}
/>
)}
</Box>
);
}
@@ -0,0 +1,412 @@
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
Box, Grid, Text, Group, Loader, Center, ThemeIcon, Badge, Stack,
RingProgress, Tooltip, SimpleGrid,
} from '@mantine/core';
import {
IconUsers, IconFileText, IconAlertTriangle, IconScale,
IconId, IconStethoscope, IconMedal, IconClipboardList,
} from '@tabler/icons-react';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import type { DashboardStats } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const orange = '#f15a31';
const red = '#b11116';
// ── Stat card ──────────────────────────────────────────────
function StatCard({
label, value, sub, color, icon,
}: {
label: string;
value: number | string;
sub?: string;
color?: string;
icon: React.ReactNode;
}) {
return (
<Box
className="hrm-lift"
style={{
background: '#fff',
border: '1px solid #e9ecef',
borderTop: `3px solid ${color ?? teal}`,
borderRadius: 8,
padding: '20px 24px',
cursor: 'default',
}}
>
<Group justify="space-between" align="flex-start">
<Box>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.75rem', color: '#adb5bd', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 }}>
{label}
</Text>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '2rem', color: color ?? teal, lineHeight: 1 }}>
{value}
</Text>
{sub && (
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.75rem', color: '#adb5bd', marginTop: 6 }}>
{sub}
</Text>
)}
</Box>
<ThemeIcon size={44} radius="md" style={{ background: (color ?? teal) + '18', color: color ?? teal }}>
{icon}
</ThemeIcon>
</Group>
</Box>
);
}
// ── Expiration row ─────────────────────────────────────────
function ExpirationRow({
name, sub, date, urgency, onClick,
}: {
name: string;
sub: string;
date: string;
urgency: 'critical' | 'warning' | 'info';
onClick?: () => void;
}) {
const daysLeft = dayjs(date).diff(dayjs(), 'day');
const color = urgency === 'critical' ? red : urgency === 'warning' ? orange : '#f59f00';
return (
<Group
justify="space-between"
py={10}
px={14}
style={{
borderBottom: '1px solid #f1f3f5',
cursor: onClick ? 'pointer' : 'default',
borderRadius: 4,
background: urgency === 'critical' ? '#fff5f5' : 'transparent',
transition: 'background 140ms ease',
}}
onMouseEnter={(e) => { if (onClick) (e.currentTarget as HTMLElement).style.background = urgency === 'critical' ? '#ffe8e8' : '#f8f9fa'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = urgency === 'critical' ? '#fff5f5' : 'transparent'; }}
onClick={onClick}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.85rem', color: charcoal }} truncate>
{name}
</Text>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.75rem', color: '#adb5bd' }}>
{sub}
</Text>
</Box>
<Box style={{ textAlign: 'right', flexShrink: 0, marginLeft: 12 }}>
<Badge
size="sm"
style={{ background: color + '22', color, fontFamily: font, fontWeight: 700, border: `1px solid ${color}44` }}
>
{dayjs(date).format('DD.MM.YYYY')}
</Badge>
<Text style={{ fontFamily: font, fontSize: '0.7rem', color, marginTop: 2 }}>
{daysLeft <= 0 ? 'expirat' : `${daysLeft} zile`}
</Text>
</Box>
</Group>
);
}
// ── Section card ───────────────────────────────────────────
function SectionCard({ title, icon, color, children, count }: {
title: string;
icon: React.ReactNode;
color?: string;
children: React.ReactNode;
count?: number;
}) {
return (
<Box className="hrm-lift" style={{ background: '#fff', border: '1px solid #e9ecef', borderRadius: 8 }}>
<Group
px={16}
py={12}
style={{ borderBottom: '1px solid #e9ecef' }}
>
{icon}
<Text style={{ fontFamily: font, fontWeight: 600, color: color ?? teal, fontSize: '0.9rem', flex: 1 }}>
{title}
</Text>
{count !== undefined && count > 0 && (
<Badge size="sm" style={{ background: (color ?? teal) + '22', color: color ?? teal, fontFamily: font, fontWeight: 700 }}>
{count}
</Badge>
)}
</Group>
<Box>{children}</Box>
</Box>
);
}
const TIP_CHECKUP_LABEL: Record<string, string> = {
la_angajare: 'La angajare',
periodic: 'Periodic',
la_reluarea_activitatii: 'La reluarea activității',
la_incetarea_expunerii: 'La încetarea expunerii',
suplimentar: 'Suplimentar',
};
const TIP_ACT_LABEL: Record<string, string> = {
buletin_de_identitate: 'Buletin identitate',
pasaport: 'Pașaport',
};
const QUAL_LABEL: Record<string, string> = {
fara: 'Fără categorie',
cat_II: 'Categoria II',
cat_I: 'Categoria I',
superioara: 'Superioară',
};
// ── Main page ──────────────────────────────────────────────
export function DashboardPage() {
const navigate = useNavigate();
const { data: stats, isLoading } = useQuery<DashboardStats>({
queryKey: ['dashboard-stats'],
queryFn: () => apiClient.get<DashboardStats>('/dashboard/stats').then((r) => r.data),
staleTime: 60_000,
});
if (isLoading || !stats) {
return <Center h={400}><Loader color="medpark" /></Center>;
}
const { employees, activeContracts, recentHires, activeSanctions, expirations } = stats;
const totalAlerts =
expirations.contractsDeterminata.length +
expirations.expiringDocs.length +
expirations.upcomingCheckups.length +
expirations.expiringQualifications.length;
return (
<Box>
{/* Page title */}
<Group mb={28} align="flex-end">
<Box>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '1.5rem', color: charcoal }}>
Dashboard HR
</Text>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.85rem', color: '#adb5bd' }}>
Medpark International Hospital · {dayjs().format('DD MMMM YYYY')}
</Text>
</Box>
{totalAlerts > 0 && (
<Badge
size="lg"
className="hrm-badge-pop"
style={{ background: orange + '22', color: orange, fontFamily: font, fontWeight: 700, border: `1px solid ${orange}44` }}
>
{totalAlerts} alerte active
</Badge>
)}
</Group>
{/* KPI cards */}
<SimpleGrid cols={{ base: 2, md: 4 }} spacing={16} mb={28} className="hrm-stagger">
<StatCard
label="Angajați activi"
value={employees.activ}
sub={`${employees.total} total (${employees.concediat} concediați)`}
color={teal}
icon={<IconUsers size={22} stroke={1.5} />}
/>
<StatCard
label="Contracte active"
value={activeContracts}
sub={`${recentHires} angajări în ultimele 30 zile`}
color="#1c7ed6"
icon={<IconFileText size={22} stroke={1.5} />}
/>
<StatCard
label="Alerte expirare"
value={totalAlerts}
sub="docs, contracte, calificări, medical"
color={totalAlerts > 0 ? orange : teal}
icon={<IconAlertTriangle size={22} stroke={1.5} />}
/>
<StatCard
label="Sancțiuni active"
value={activeSanctions}
sub="nestigate"
color={activeSanctions > 0 ? red : teal}
icon={<IconScale size={22} stroke={1.5} />}
/>
</SimpleGrid>
{/* Employee status ring + expirations */}
<Grid gutter={20}>
{/* Left: status ring */}
<Grid.Col span={{ base: 12, md: 3 }}>
<Box style={{ background: '#fff', border: '1px solid #e9ecef', borderRadius: 8, padding: '20px 16px', height: '100%' }}>
<Text style={{ fontFamily: font, fontWeight: 600, color: teal, fontSize: '0.9rem', marginBottom: 16 }}>
Structura angajaților
</Text>
<Center>
<Tooltip label={`${employees.activ} activi`}>
<RingProgress
size={160}
thickness={18}
roundCaps
sections={[
{ value: employees.total > 0 ? (employees.activ / employees.total) * 100 : 0, color: teal },
{ value: employees.total > 0 ? (employees.suspendat / employees.total) * 100 : 0, color: orange },
{ value: employees.total > 0 ? (employees.concediat / employees.total) * 100 : 0, color: '#dee2e6' },
].filter((s) => s.value > 0)}
label={
<Center>
<Box style={{ textAlign: 'center' }}>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '1.8rem', color: teal, lineHeight: 1 }}>
{employees.total}
</Text>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.7rem', color: '#adb5bd' }}>total</Text>
</Box>
</Center>
}
/>
</Tooltip>
</Center>
<Stack gap={8} mt={16}>
{[
{ label: 'Activi', value: employees.activ, color: teal },
{ label: 'Suspendați', value: employees.suspendat, color: orange },
{ label: 'Concediați', value: employees.concediat, color: '#adb5bd' },
].map(({ label, value, color }) => (
<Group key={label} justify="space-between">
<Group gap={6}>
<Box style={{ width: 10, height: 10, borderRadius: 2, background: color, flexShrink: 0 }} />
<Text style={{ fontFamily: font, fontWeight: 400, fontSize: '0.8rem', color: charcoal }}>{label}</Text>
</Group>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '0.85rem', color }}>{value}</Text>
</Group>
))}
</Stack>
</Box>
</Grid.Col>
{/* Right: expiration alerts */}
<Grid.Col span={{ base: 12, md: 9 }}>
<Grid gutter={16}>
{/* Expiring documents */}
<Grid.Col span={{ base: 12, md: 6 }}>
<SectionCard
title="Documente identitate"
icon={<IconId size={18} stroke={1.5} color={expirations.expiringDocs.length > 0 ? orange : teal} />}
color={expirations.expiringDocs.length > 0 ? orange : teal}
count={expirations.expiringDocs.length}
>
{expirations.expiringDocs.length === 0 ? (
<Text c="#adb5bd" ta="center" py={24} style={{ fontFamily: font, fontWeight: 300, fontSize: '0.8rem' }}>
Nicio expirare în 30 zile
</Text>
) : (
expirations.expiringDocs.map((d) => (
<ExpirationRow
key={d.id}
name={`${d.employee.prenume} ${d.employee.nume}`}
sub={TIP_ACT_LABEL[d.tipAct] ?? d.tipAct}
date={d.dataExpirarii}
urgency={dayjs(d.dataExpirarii).diff(dayjs(), 'day') <= 7 ? 'critical' : 'warning'}
onClick={() => navigate(`/employees/${d.employee.id}`)}
/>
))
)}
</SectionCard>
</Grid.Col>
{/* Upcoming checkups */}
<Grid.Col span={{ base: 12, md: 6 }}>
<SectionCard
title="Control medical planificat"
icon={<IconStethoscope size={18} stroke={1.5} color={expirations.upcomingCheckups.length > 0 ? '#1c7ed6' : teal} />}
color={expirations.upcomingCheckups.length > 0 ? '#1c7ed6' : teal}
count={expirations.upcomingCheckups.length}
>
{expirations.upcomingCheckups.length === 0 ? (
<Text c="#adb5bd" ta="center" py={24} style={{ fontFamily: font, fontWeight: 300, fontSize: '0.8rem' }}>
Niciun control medical în 60 zile
</Text>
) : (
expirations.upcomingCheckups.map((c) => (
<ExpirationRow
key={c.id}
name={`${c.employee.prenume} ${c.employee.nume}`}
sub={TIP_CHECKUP_LABEL[c.tip] ?? c.tip}
date={c.dataPlanificata}
urgency={dayjs(c.dataPlanificata).diff(dayjs(), 'day') <= 7 ? 'critical' : 'info'}
onClick={() => navigate(`/employees/${c.employee.id}`)}
/>
))
)}
</SectionCard>
</Grid.Col>
{/* Expiring qualifications */}
<Grid.Col span={{ base: 12, md: 6 }}>
<SectionCard
title="Calificări"
icon={<IconMedal size={18} stroke={1.5} color={expirations.expiringQualifications.length > 0 ? orange : teal} />}
color={expirations.expiringQualifications.length > 0 ? orange : teal}
count={expirations.expiringQualifications.length}
>
{expirations.expiringQualifications.length === 0 ? (
<Text c="#adb5bd" ta="center" py={24} style={{ fontFamily: font, fontWeight: 300, fontSize: '0.8rem' }}>
Nicio calificare în 90 zile
</Text>
) : (
expirations.expiringQualifications.map((q) => (
<ExpirationRow
key={q.id}
name={`${q.employee.prenume} ${q.employee.nume}`}
sub={QUAL_LABEL[q.categorie] ?? q.categorie}
date={q.dataExpirarii}
urgency={dayjs(q.dataExpirarii).diff(dayjs(), 'day') <= 30 ? 'warning' : 'info'}
onClick={() => navigate(`/employees/${q.employee.id}`)}
/>
))
)}
</SectionCard>
</Grid.Col>
{/* Determinata contracts */}
<Grid.Col span={{ base: 12, md: 6 }}>
<SectionCard
title="Contracte determinată"
icon={<IconClipboardList size={18} stroke={1.5} color={expirations.contractsDeterminata.length > 0 ? red : teal} />}
color={expirations.contractsDeterminata.length > 0 ? red : teal}
count={expirations.contractsDeterminata.length}
>
{expirations.contractsDeterminata.length === 0 ? (
<Text c="#adb5bd" ta="center" py={24} style={{ fontFamily: font, fontWeight: 300, fontSize: '0.8rem' }}>
Niciun contract în 30 zile
</Text>
) : (
expirations.contractsDeterminata.map((c) => (
<ExpirationRow
key={c.id}
name={`${c.employee.prenume} ${c.employee.nume}`}
sub={`CIM ${c.nrCim} · ${c.department.name}`}
date={c.dataTerminarii}
urgency={dayjs(c.dataTerminarii).diff(dayjs(), 'day') <= 7 ? 'critical' : 'warning'}
onClick={() => navigate(`/employees/${c.employee.id}`)}
/>
))
)}
</SectionCard>
</Grid.Col>
</Grid>
</Grid.Col>
</Grid>
</Box>
);
}
@@ -0,0 +1,521 @@
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
TextInput, Select, Modal, LoadingOverlay, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { notifications } from '@mantine/notifications';
import { IconTrash, IconGripVertical, IconArrowsMove, IconPencil, IconCheck, IconX, IconArrowBackUp } from '@tabler/icons-react';
import { DndContext, DragOverlay, useDraggable, useDroppable, pointerWithin } from '@dnd-kit/core';
import { apiClient } from '../../api/client';
import type { Department } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
// ── Root drop zone ────────────────────────────────────────────
function RootDropZone({ dragMode }: { dragMode: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: '__root__', disabled: !dragMode });
if (!dragMode) return null;
return (
<div
ref={setNodeRef}
style={{
height: 44,
margin: '0 12px 8px',
border: `2px dashed ${isOver ? teal : '#dee2e6'}`,
borderRadius: 6,
background: isOver ? '#e6f4f4' : '#fafafa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.12s',
}}
>
<Text size="xs" c={isOver ? teal : '#adb5bd'} style={{ fontFamily: font }}>
Plasați aici pentru nivel principal (rădăcină)
</Text>
</div>
);
}
// ── Single dept row ───────────────────────────────────────────
function DeptRow({
dept,
level,
dragMode,
activeId,
onDelete,
}: {
dept: Department;
level: number;
dragMode: boolean;
activeId: string | null;
onDelete: (dept: Department) => void;
}) {
const qc = useQueryClient();
const [expanded, setExpanded] = useState(true);
const [editing, setEditing] = useState(false);
const [editVal, setEditVal] = useState(dept.name);
const inputRef = useRef<HTMLInputElement>(null);
const hasChildren = (dept.children?.length ?? 0) > 0;
// Drag handle only (grip icon) — keeps delete/edit buttons click-safe
const { setNodeRef: setDragRef, listeners, attributes, isDragging } = useDraggable({
id: dept.id,
disabled: !dragMode,
});
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: dept.id,
disabled: !dragMode,
});
const showHighlight = isOver && dragMode && activeId !== dept.id;
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const renameMutation = useMutation({
mutationFn: (name: string) => apiClient.patch(`/departments/${dept.id}`, { name }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['departments'] });
void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] });
setEditing(false);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la redenumire.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const commitRename = () => {
const v = editVal.trim();
if (!v || v === dept.name) { setEditVal(dept.name); setEditing(false); return; }
renameMutation.mutate(v);
};
const cancelRename = () => { setEditVal(dept.name); setEditing(false); };
return (
<>
<div
ref={setDropRef}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '9px 16px',
paddingLeft: `${16 + level * 24}px`,
borderBottom: `1px solid ${border}`,
background: showHighlight ? '#e6f4f4' : isDragging ? '#f8f9fa' : 'transparent',
opacity: isDragging ? 0.35 : 1,
outline: showHighlight ? `2px solid ${teal}` : 'none',
outlineOffset: -2,
transition: 'background 0.1s, outline 0.1s',
userSelect: 'none',
}}
onMouseEnter={(e) => { if (!dragMode) (e.currentTarget as HTMLElement).style.background = '#f8f9fa'; }}
onMouseLeave={(e) => { if (!dragMode && !showHighlight) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{/* Drag handle — listeners only on grip icon */}
{dragMode && (
<span
ref={setDragRef}
{...listeners}
{...attributes}
tabIndex={-1}
style={{ cursor: 'grab', flexShrink: 0, display: 'flex', alignItems: 'center', touchAction: 'none', outline: 'none' }}
>
<IconGripVertical size={14} color="#adb5bd" />
</span>
)}
{/* Expand/collapse toggle */}
<span style={{ width: 14, flexShrink: 0 }}>
{hasChildren && (
<Text
component="span"
c={teal}
style={{ cursor: 'pointer', fontSize: '0.7rem' }}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? '▼' : '▶'}
</Text>
)}
</span>
{/* Name — text or inline input */}
{editing ? (
<input
ref={inputRef}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={commitRename}
style={{
flex: 1,
fontFamily: font,
fontWeight: level === 0 ? 500 : 300,
fontSize: '0.875rem',
color: charcoal,
border: `1px solid ${teal}`,
borderRadius: 4,
padding: '2px 8px',
outline: 'none',
background: '#fff',
}}
/>
) : (
<Text
style={{
fontFamily: font,
fontWeight: level === 0 ? 500 : 300,
fontSize: '0.875rem',
color: charcoal,
flex: 1,
}}
>
{dept.name}
</Text>
)}
{/* Actions */}
{editing ? (
<Group gap={2}>
<ActionIcon variant="subtle" color="medpark" size="sm" onMouseDown={(e) => { e.preventDefault(); commitRename(); }}>
<IconCheck size={14} stroke={2} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" size="sm" onMouseDown={(e) => { e.preventDefault(); cancelRename(); }}>
<IconX size={14} stroke={2} />
</ActionIcon>
</Group>
) : (
<Group gap={2} style={{ flexShrink: 0 }}>
{!dragMode && (
<Tooltip label="Redenumește">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={(e) => { e.stopPropagation(); setEditVal(dept.name); setEditing(true); }}
>
<IconPencil size={13} stroke={1.5} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Șterge departament">
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={(e) => { e.stopPropagation(); onDelete(dept); }}
>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
)}
</div>
{expanded && dept.children?.map((child) => (
<DeptRow key={child.id} dept={child} level={level + 1} dragMode={dragMode} activeId={activeId} onDelete={onDelete} />
))}
</>
);
}
// ── Page ─────────────────────────────────────────────────────
interface DeptFormValues { name: string; parentId?: string }
function findName(depts: Department[], id: string): string {
for (const d of depts) {
if (d.id === id) return d.name;
const hit = findName(d.children ?? [], id);
if (hit) return hit;
}
return '';
}
// Returns the parentId of the node with given id (null = root-level)
function findParentId(depts: Department[], id: string, parentId: string | null = null): string | null | undefined {
for (const d of depts) {
if (d.id === id) return parentId;
const hit = findParentId(d.children ?? [], id, d.id);
if (hit !== undefined) return hit;
}
return undefined;
}
export function DepartmentsPage() {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
const [dragMode, setDragMode] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [moveHistory, setMoveHistory] = useState<{ id: string; name: string; prevParentId: string | null }[]>([]);
const { data, isLoading } = useQuery({
queryKey: ['departments'],
queryFn: () => apiClient.get<Department[]>('/departments').then((r) => r.data),
});
const { data: flat } = useQuery({
queryKey: ['ref', 'departments-flat'],
queryFn: () => apiClient.get<Department[]>('/reference/departments/flat').then((r) => r.data),
staleTime: 300_000,
});
const { register, handleSubmit, reset, setValue, watch, formState: { isSubmitting } } = useForm<DeptFormValues>();
const createMutation = useMutation({
mutationFn: (d: DeptFormValues) =>
apiClient.post('/departments', { ...d, parentId: d.parentId || undefined }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['departments'] });
void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] });
notifications.show({ color: 'medpark', title: 'Creat', message: 'Departament adăugat.' });
setModalOpen(false);
reset();
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut crea.' }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/departments/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['departments'] });
void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Departamentul a fost șters.' });
setDeleteTarget(null);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la ștergere.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
setDeleteTarget(null);
},
});
const moveMutation = useMutation({
mutationFn: ({ id, parentId }: { id: string; parentId: string | null }) =>
apiClient.patch(`/departments/${id}`, { parentId }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['departments'] });
void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] });
notifications.show({ color: 'medpark', title: 'Reorganizat', message: 'Structura departamentelor actualizată.' });
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la mutare.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const undoMutation = useMutation({
mutationFn: ({ id, prevParentId }: { id: string; prevParentId: string | null }) =>
apiClient.patch(`/departments/${id}`, { parentId: prevParentId }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['departments'] });
void qc.invalidateQueries({ queryKey: ['ref', 'departments-flat'] });
setMoveHistory((h) => h.slice(0, -1));
notifications.show({ color: 'gray', title: 'Anulat', message: 'Ultima mutare a fost anulată.' });
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la anulare.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
return (
<Stack gap={24}>
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.5rem', marginBottom: 4 }}>
Departamente
</Title>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2 }} />
</Box>
<Group gap={8}>
{dragMode && moveHistory.length > 0 && (
<Tooltip label={`Anulează: mutarea „${moveHistory[moveHistory.length - 1]?.name}"`} withArrow>
<Button
variant="subtle"
leftSection={<IconArrowBackUp size={16} stroke={1.6} />}
loading={undoMutation.isPending}
onClick={() => {
const last = moveHistory[moveHistory.length - 1];
if (last) undoMutation.mutate({ id: last.id, prevParentId: last.prevParentId });
}}
style={{ color: charcoal, fontFamily: font, fontWeight: 500, height: 40 }}
>
Anulează
</Button>
</Tooltip>
)}
<Button
variant="outline"
leftSection={<IconArrowsMove size={16} stroke={1.6} />}
onClick={() => { setDragMode((v) => !v); setMoveHistory([]); }}
style={{
borderColor: teal,
color: dragMode ? '#fff' : teal,
background: dragMode ? teal : 'transparent',
fontFamily: font,
fontWeight: 500,
height: 40,
}}
>
{dragMode ? 'Gata' : 'Reorganizare'}
</Button>
{!dragMode && (
<Button
onClick={() => setModalOpen(true)}
style={{ background: teal, fontFamily: font, fontWeight: 500, height: 40, paddingLeft: 20, paddingRight: 20 }}
>
+ Adaugă departament
</Button>
)}
</Group>
</Group>
<DndContext
collisionDetection={pointerWithin}
onDragStart={({ active }) => setActiveId(active.id as string)}
onDragEnd={({ active, over }) => {
setActiveId(null);
if (!over || active.id === over.id) return;
const id = active.id as string;
const prevParentId = findParentId(data ?? [], id) ?? null;
const name = findName(data ?? [], id);
setMoveHistory((h) => [...h, { id, name, prevParentId }]);
moveMutation.mutate({
id,
parentId: over.id === '__root__' ? null : over.id as string,
});
}}
onDragCancel={() => setActiveId(null)}
>
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#ffffff' }}>
{/* Header */}
<div style={{ padding: '14px 16px', borderBottom: `2px solid ${teal}`, background: '#fafafa' }}>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal }}>
Denumire
</Text>
</div>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<div style={{ paddingTop: dragMode ? 8 : 0 }}>
<RootDropZone dragMode={dragMode} />
{!data?.length ? (
<div style={{ textAlign: 'center', padding: 48, fontFamily: font, fontWeight: 300, color: '#adb5bd', fontSize: '0.875rem' }}>
Niciun departament. Adăugați primul departament.
</div>
) : (
data.map((dept) => (
<DeptRow key={dept.id} dept={dept} level={0} dragMode={dragMode} activeId={activeId} onDelete={setDeleteTarget} />
))
)}
</div>
)}
</Paper>
<DragOverlay dropAnimation={null}>
{activeId && data ? (
<div style={{
padding: '10px 16px',
background: '#fff',
border: `2px solid ${teal}`,
borderRadius: 6,
boxShadow: '0 8px 24px rgba(0,130,134,0.18)',
fontFamily: font,
fontWeight: 500,
fontSize: '0.875rem',
color: charcoal,
cursor: 'grabbing',
display: 'flex',
alignItems: 'center',
gap: 8,
pointerEvents: 'none',
}}>
<IconGripVertical size={14} color={teal} />
{findName(data, activeId)}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* Create Modal */}
<Modal
opened={modalOpen}
onClose={() => { setModalOpen(false); reset(); }}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>Departament nou</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
<Stack gap={12} mt={4}>
<TextInput
label="Denumire *"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
{...register('name', { required: true })}
/>
<Select
label="Departament părinte"
clearable
data={(flat ?? []).map((d) => ({ value: d.id, label: d.name }))}
value={watch('parentId') ?? null}
onChange={(v) => setValue('parentId', v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => { setModalOpen(false); reset(); }} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
Salvează
</Button>
</Group>
</form>
</Box>
</Modal>
{/* Delete Confirm */}
<Modal
opened={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
Sigur doriți ștergeți departamentul <strong>{deleteTarget?.name}</strong>? Această acțiune este ireversibilă.
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setDeleteTarget(null)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
style={{ fontFamily: font, fontWeight: 500 }}
>
Șterge
</Button>
</Group>
</Modal>
</Stack>
);
}
@@ -0,0 +1,365 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Tabs, Box, Loader, Center, Text, Alert, Modal, Group, Button } from '@mantine/core';
import { IconFileText, IconStethoscope } from '@tabler/icons-react';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import type {
Employee,
IdentityDocument,
FamilyMember,
Education,
Qualification,
Training,
DisciplinarySanction,
EmploymentContract,
} from '../../api/types';
import { EmployeeHeader } from './components/EmployeeHeader';
import { PersonalTab } from './tabs/PersonalTab';
import { DocumenteTab } from './tabs/DocumenteTab';
import { FamilieTab } from './tabs/FamilieTab';
import { StudiiTab } from './tabs/StudiiTab';
import { CalificariTab } from './tabs/CalificariTab';
import { TrainingTab } from './tabs/TrainingTab';
import { SanctiuniTab } from './tabs/SanctiuniTab';
import { BeneficiiTab } from './tabs/BeneficiiTab';
import { EmployeeMedicalTab } from './tabs/MedicalTab';
import { ContracteTab } from './tabs/ContracteTab';
import { EmployeeDrawer } from './components/EmployeeDrawer';
import { IdentityDocumentDrawer } from './drawers/IdentityDocumentDrawer';
import { FamilyMemberDrawer } from './drawers/FamilyMemberDrawer';
import { EducationDrawer } from './drawers/EducationDrawer';
import { QualificationDrawer } from './drawers/QualificationDrawer';
import { TrainingDrawer } from './drawers/TrainingDrawer';
import { DisciplinarySanctionDrawer } from './drawers/DisciplinarySanctionDrawer';
import { BenefitDrawer } from './drawers/BenefitDrawer';
import { ContractDrawer } from './drawers/ContractDrawer';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
export function EmployeeDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: employee, isLoading, error } = useQuery({
queryKey: ['employee', id],
queryFn: () => apiClient.get<Employee>(`/employees/${id}`).then((r) => r.data),
enabled: !!id,
});
// Drawer states
const [editOpen, setEditOpen] = useState(false);
const [docDrawer, setDocDrawer] = useState<{ open: boolean; record?: IdentityDocument }>({ open: false });
const [familyDrawer, setFamilyDrawer] = useState<{ open: boolean; record?: FamilyMember }>({ open: false });
const [eduDrawer, setEduDrawer] = useState<{ open: boolean; record?: Education }>({ open: false });
const [qualDrawer, setQualDrawer] = useState<{ open: boolean; record?: Qualification }>({ open: false });
const [trainDrawer, setTrainDrawer] = useState<{ open: boolean; record?: Training }>({ open: false });
const [sanctDrawer, setSanctDrawer] = useState<{ open: boolean; record?: DisciplinarySanction }>({ open: false });
const [benefitDrawer, setBenefitDrawer] = useState(false);
const [contractDrawer, setContractDrawer] = useState<{ open: boolean; record?: EmploymentContract }>({ open: false });
const [terminateModal, setTerminateModal] = useState<{ open: boolean; contract?: EmploymentContract }>({ open: false });
const [deleteContractModal, setDeleteContractModal] = useState<{ open: boolean; contract?: EmploymentContract }>({ open: false });
const [terminateDate, setTerminateDate] = useState<Date | null>(new Date());
const qc = useQueryClient();
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => apiClient.delete(`/employees/${id}/contracts/${contractId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employee', id] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Contractul a fost șters.' });
setDeleteContractModal({ open: false });
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut șterge contractul.' }),
});
const terminateMutation = useMutation({
mutationFn: ({ contractId, date }: { contractId: string; date: string }) =>
apiClient.patch(`/employees/${id}/contracts/${contractId}/terminate`, { dataDemisiei: date }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employee', id] });
notifications.show({ color: 'medpark', title: 'Contract încetat', message: 'Data demisiei a fost înregistrată.' });
setTerminateModal({ open: false });
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut termina contractul.' }),
});
if (isLoading) {
return <Center h={400}><Loader color="medpark" /></Center>;
}
if (error || !employee) {
return (
<Alert color="red" title="Eroare">
<Text style={{ fontFamily: font, fontWeight: 300 }}>Angajatul nu a fost găsit.</Text>
</Alert>
);
}
return (
<Box>
<EmployeeHeader employee={employee} onEdit={() => setEditOpen(true)} />
<Tabs
mt={20}
defaultValue="personal"
color={teal}
styles={{
tab: {
fontFamily: font,
fontWeight: 500,
fontSize: '0.875rem',
color: '#6c757d',
},
list: { borderBottom: `2px solid #e9ecef` },
}}
>
<Tabs.List>
<Tabs.Tab value="contracte">
<Group gap={5} align="center"><IconFileText size={14} stroke={1.5} />Contracte</Group>
{employee.contracts.filter((c) => !c.dataDemisiei).length > 0 && (
<Box component="span" ml={6} style={{
background: teal, color: '#fff', borderRadius: 10,
padding: '0 6px', fontSize: '0.65rem', fontWeight: 700,
}}>
{employee.contracts.filter((c) => !c.dataDemisiei).length}
</Box>
)}
</Tabs.Tab>
<Tabs.Tab value="personal">Personal</Tabs.Tab>
<Tabs.Tab value="documente">
Documente
{employee.identityDocuments.length > 0 && (
<Box component="span" ml={6} style={{
background: teal, color: '#fff', borderRadius: 10,
padding: '0 6px', fontSize: '0.65rem', fontWeight: 700,
}}>
{employee.identityDocuments.length}
</Box>
)}
</Tabs.Tab>
<Tabs.Tab value="familie">Familie</Tabs.Tab>
<Tabs.Tab value="studii">Studii</Tabs.Tab>
<Tabs.Tab value="calificari">Calificări</Tabs.Tab>
<Tabs.Tab value="training">Training</Tabs.Tab>
<Tabs.Tab value="sanctiuni">
Sancțiuni
{employee.disciplinarySanctions.some((s) => !s.isStinsa) && (
<Box component="span" ml={6} style={{
background: '#b11116', color: '#fff', borderRadius: 10,
padding: '0 6px', fontSize: '0.65rem', fontWeight: 700,
}}>
{employee.disciplinarySanctions.filter((s) => !s.isStinsa).length}
</Box>
)}
</Tabs.Tab>
<Tabs.Tab value="beneficii">Beneficii</Tabs.Tab>
<Tabs.Tab value="medical">
<Group gap={5} align="center"><IconStethoscope size={14} stroke={1.5} />Medical</Group>
</Tabs.Tab>
</Tabs.List>
<Box p={20} style={{ background: '#ffffff', border: '1px solid #e9ecef', borderTop: 'none', borderRadius: '0 0 8px 8px' }}>
<Tabs.Panel value="contracte">
<ContracteTab
contracts={employee.contracts}
onAdd={() => setContractDrawer({ open: true })}
onEdit={(c) => setContractDrawer({ open: true, record: c })}
onTerminate={(c) => { setTerminateModal({ open: true, contract: c }); setTerminateDate(new Date()); }}
onDelete={(c) => setDeleteContractModal({ open: true, contract: c })}
/>
</Tabs.Panel>
<Tabs.Panel value="personal">
<PersonalTab employee={employee} />
</Tabs.Panel>
<Tabs.Panel value="documente">
<DocumenteTab
documents={employee.identityDocuments}
employeeId={employee.id}
onAdd={() => setDocDrawer({ open: true })}
onEdit={(doc) => setDocDrawer({ open: true, record: doc })}
/>
</Tabs.Panel>
<Tabs.Panel value="familie">
<FamilieTab
members={employee.familyMembers}
onAdd={() => setFamilyDrawer({ open: true })}
onEdit={(m) => setFamilyDrawer({ open: true, record: m })}
/>
</Tabs.Panel>
<Tabs.Panel value="studii">
<StudiiTab
educations={employee.educations}
onAdd={() => setEduDrawer({ open: true })}
onEdit={(e) => setEduDrawer({ open: true, record: e })}
/>
</Tabs.Panel>
<Tabs.Panel value="calificari">
<CalificariTab
qualifications={employee.qualifications}
onAdd={() => setQualDrawer({ open: true })}
onEdit={(q) => setQualDrawer({ open: true, record: q })}
/>
</Tabs.Panel>
<Tabs.Panel value="training">
<TrainingTab
trainings={employee.trainings}
onAdd={() => setTrainDrawer({ open: true })}
onEdit={(t) => setTrainDrawer({ open: true, record: t })}
/>
</Tabs.Panel>
<Tabs.Panel value="sanctiuni">
<SanctiuniTab
sanctions={employee.disciplinarySanctions}
onAdd={() => setSanctDrawer({ open: true })}
onEdit={(s) => setSanctDrawer({ open: true, record: s })}
/>
</Tabs.Panel>
<Tabs.Panel value="beneficii">
<BeneficiiTab
benefit={employee.benefit}
onEdit={() => setBenefitDrawer(true)}
/>
</Tabs.Panel>
<Tabs.Panel value="medical">
<EmployeeMedicalTab employeeId={employee.id} />
</Tabs.Panel>
</Box>
</Tabs>
{/* ── Drawers ── */}
<EmployeeDrawer
opened={editOpen}
onClose={() => setEditOpen(false)}
employeeId={employee.id}
/>
<IdentityDocumentDrawer
employeeId={employee.id}
record={docDrawer.record}
opened={docDrawer.open}
onClose={() => setDocDrawer({ open: false })}
/>
<FamilyMemberDrawer
employeeId={employee.id}
record={familyDrawer.record}
opened={familyDrawer.open}
onClose={() => setFamilyDrawer({ open: false })}
/>
<EducationDrawer
employeeId={employee.id}
record={eduDrawer.record}
opened={eduDrawer.open}
onClose={() => setEduDrawer({ open: false })}
/>
<QualificationDrawer
employeeId={employee.id}
record={qualDrawer.record}
opened={qualDrawer.open}
onClose={() => setQualDrawer({ open: false })}
/>
<TrainingDrawer
employeeId={employee.id}
record={trainDrawer.record}
opened={trainDrawer.open}
onClose={() => setTrainDrawer({ open: false })}
/>
<DisciplinarySanctionDrawer
employeeId={employee.id}
record={sanctDrawer.record}
opened={sanctDrawer.open}
onClose={() => setSanctDrawer({ open: false })}
/>
<BenefitDrawer
employeeId={employee.id}
benefit={employee.benefit}
opened={benefitDrawer}
onClose={() => setBenefitDrawer(false)}
/>
<ContractDrawer
employeeId={employee.id}
record={contractDrawer.record}
opened={contractDrawer.open}
onClose={() => setContractDrawer({ open: false })}
/>
{/* Delete Contract Modal */}
<Modal
opened={deleteContractModal.open}
onClose={() => setDeleteContractModal({ open: false })}
title={<Text style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 300, color: '#58595b' }}>
Sigur doriți ștergeți CIM {deleteContractModal.contract?.nrCim}? Această acțiune este ireversibilă.
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={() => setDeleteContractModal({ open: false })} style={{ fontFamily: "'Montserrat', Arial, sans-serif", color: '#58595b' }}>
Anulează
</Button>
<Button
color="red"
loading={deleteContractMutation.isPending}
onClick={() => {
if (deleteContractModal.contract) {
deleteContractMutation.mutate(deleteContractModal.contract.id);
}
}}
style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 }}
>
Șterge
</Button>
</Group>
</Modal>
{/* Terminate Contract Modal */}
<Modal
opened={terminateModal.open}
onClose={() => setTerminateModal({ open: false })}
title={<Text style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 700, color: '#58595b' }}>Încetare contract</Text>}
styles={{ header: { borderBottom: '2px solid #f15a31' } }}
size="sm"
>
<Text size="sm" mb={16} style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 300, color: '#58595b' }}>
CIM {terminateModal.contract?.nrCim} {terminateModal.contract?.functiaOrganigrama ?? ''}
</Text>
<DateInput
label="Data demisiei *"
value={terminateDate}
onChange={setTerminateDate}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500, fontSize: '0.8rem' } }}
/>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={() => setTerminateModal({ open: false })} style={{ fontFamily: "'Montserrat', Arial, sans-serif", color: '#58595b' }}>
Anulează
</Button>
<Button
color="orange"
loading={terminateMutation.isPending}
disabled={!terminateDate}
onClick={() => {
if (terminateModal.contract && terminateDate) {
terminateMutation.mutate({ contractId: terminateModal.contract.id, date: dayjs(terminateDate).format('YYYY-MM-DD') });
}
}}
style={{ fontFamily: "'Montserrat', Arial, sans-serif", fontWeight: 500 }}
>
Înregistrează demisie
</Button>
</Group>
</Modal>
</Box>
);
}
@@ -0,0 +1,378 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Title,
TextInput,
Button,
Group,
Text,
Loader,
Center,
Select,
Pagination,
Stack,
ActionIcon,
Tooltip,
Box,
Paper,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { IconSearch } from '@tabler/icons-react';
import { apiClient } from '../../api/client';
import { EmployeeDrawer } from './components/EmployeeDrawer';
type EmployeeStatus = 'activ' | 'concediat' | 'suspendat';
interface EmployeeRow {
id: string;
idnp: string;
nume: string;
prenume: string;
sex: 'F' | 'M';
status: EmployeeStatus;
telefonPersonal: string;
contracts: {
functiaOrganigrama: string | null;
department: { name: string };
}[];
}
const STATUS_CONFIG: Record<EmployeeStatus, { color: string; bg: string; label_ro: string }> = {
activ: { color: '#008286', bg: '#e6f4f4', label_ro: 'Activ' },
concediat: { color: '#b11116', bg: '#ffeaea', label_ro: 'Concediat' },
suspendat: { color: '#f15a31', bg: '#fff3ee', label_ro: 'Suspendat' },
};
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
export function EmployeesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['employees', search, status, page],
queryFn: () =>
apiClient
.get<{ total: number; items: EmployeeRow[] }>('/employees', {
params: { search: search || undefined, status: status || undefined, page },
})
.then((r) => r.data),
});
const totalPages = Math.ceil((data?.total ?? 0) / 20);
return (
<Stack gap={24}>
{/* ── Page header ── */}
<Group justify="space-between" align="flex-end">
<Box>
<Title
order={2}
style={{
fontFamily: font,
fontWeight: 700,
color: charcoal,
fontSize: '1.5rem',
marginBottom: 4,
}}
>
{t('employees.title')}
</Title>
{/* Teal accent underline */}
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2 }} />
</Box>
<Button
onClick={() => setDrawerOpen(true)}
style={{
background: teal,
fontFamily: font,
fontWeight: 500,
fontSize: '0.875rem',
height: 40,
paddingLeft: 20,
paddingRight: 20,
}}
>
+ {t('employees.add')}
</Button>
</Group>
{/* ── Filters ── */}
<Paper
shadow="none"
p={20}
style={{ border: `1px solid ${border}`, borderRadius: 8, background: '#ffffff' }}
>
<Group gap={12}>
<TextInput
placeholder={t('employees.search')}
value={search}
onChange={(e) => { setSearch(e.currentTarget.value); setPage(1); }}
style={{ flex: 1 }}
styles={{
input: {
fontFamily: font,
fontWeight: 300,
fontSize: '0.875rem',
color: charcoal,
borderColor: border,
'&:focus': { borderColor: teal },
},
}}
leftSection={<IconSearch size={16} color="#adb5bd" />}
/>
<Select
placeholder={t('employees.status.all') ?? 'Statut'}
clearable
data={[
{ value: 'activ', label: 'Activ' },
{ value: 'concediat', label: 'Concediat' },
{ value: 'suspendat', label: 'Suspendat' },
]}
value={status}
onChange={(v) => { setStatus(v); setPage(1); }}
style={{ width: 160 }}
styles={{
input: { fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal },
}}
/>
</Group>
</Paper>
{/* ── Table ── */}
<Paper
shadow="none"
style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#ffffff' }}
>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<>
<Box style={{ overflowX: 'auto' }}>
<table className="brand-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{[
t('employees.columns.idnp'),
t('employees.columns.name'),
t('employees.columns.position'),
t('employees.columns.department'),
t('employees.columns.phone'),
t('employees.columns.status'),
'',
].map((col, i) => (
<th
key={i}
style={{
fontFamily: font,
fontWeight: 600,
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: charcoal,
padding: '14px 16px',
textAlign: 'left',
borderBottom: `2px solid ${teal}`,
whiteSpace: 'nowrap',
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="hrm-stagger">
{!data?.items.length ? (
<tr>
<td
colSpan={7}
style={{
textAlign: 'center',
padding: 48,
fontFamily: font,
fontWeight: 300,
color: '#adb5bd',
fontSize: '0.875rem',
}}
>
Niciun angajat găsit
</td>
</tr>
) : (
data.items.map((emp, idx) => {
const st = STATUS_CONFIG[emp.status];
return (
<tr
key={emp.id}
onClick={() => navigate(`/employees/${emp.id}`)}
style={{
background: idx % 2 === 0 ? '#ffffff' : '#fafafa',
cursor: 'pointer',
transition: 'background 0.1s',
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLElement).style.background = '#e6f4f4')
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLElement).style.background =
idx % 2 === 0 ? '#ffffff' : '#fafafa')
}
>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
fontFamily: "'Courier New', monospace",
fontSize: '0.8rem',
color: '#6c757d',
letterSpacing: '0.04em',
}}
>
{emp.idnp}
</td>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
fontFamily: font,
fontWeight: 500,
fontSize: '0.875rem',
color: charcoal,
}}
>
{emp.nume} {emp.prenume}
</td>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
fontFamily: font,
fontWeight: 300,
fontSize: '0.875rem',
color: charcoal,
}}
>
{emp.contracts[0]?.functiaOrganigrama ?? (
<Text c="#ced4da" size="sm" style={{ fontFamily: font }}></Text>
)}
</td>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
fontFamily: font,
fontWeight: 300,
fontSize: '0.875rem',
color: charcoal,
}}
>
{emp.contracts[0]?.department.name ?? (
<Text c="#ced4da" size="sm" style={{ fontFamily: font }}></Text>
)}
</td>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
fontFamily: font,
fontWeight: 300,
fontSize: '0.875rem',
color: charcoal,
}}
>
{emp.telefonPersonal}
</td>
<td
style={{
padding: '13px 16px',
borderBottom: `1px solid ${border}`,
}}
>
<Box
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: 20,
background: st.bg,
color: st.color,
fontFamily: font,
fontWeight: 600,
fontSize: '0.72rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{st.label_ro}
</Box>
</td>
<td
style={{
padding: '13px 12px',
borderBottom: `1px solid ${border}`,
textAlign: 'right',
}}
>
<Tooltip label="Vizualizează" position="left" withArrow>
<ActionIcon
variant="subtle"
color="medpark"
size="sm"
style={{ fontFamily: font }}
>
</ActionIcon>
</Tooltip>
</td>
</tr>
);
})
)}
</tbody>
</table>
</Box>
{/* Footer: count + pagination */}
{(data?.total ?? 0) > 0 && (
<Group justify="space-between" px={20} py={14} style={{ borderTop: `1px solid ${border}` }}>
<Text
size="xs"
c="#adb5bd"
style={{ fontFamily: font, fontWeight: 300 }}
>
{data?.total} angajați total
</Text>
{totalPages > 1 && (
<Pagination
total={totalPages}
value={page}
onChange={setPage}
size="sm"
color="medpark"
styles={{
control: { fontFamily: font, fontWeight: 500 },
}}
/>
)}
</Group>
)}
</>
)}
</Paper>
<EmployeeDrawer
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
</Stack>
);
}
@@ -0,0 +1,367 @@
import { useEffect } from 'react';
import {
Drawer, TextInput, Select, SegmentedControl, Button,
Group, Stack, Box, Text, Loader, LoadingOverlay,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { Employee, DisabilityGrade } from '../../../api/types';
import { employeeSchema, type EmployeeFormValues } from '../employeeSchema';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
function idnpValid(v: string): boolean {
if (!/^\d{13}$/.test(v)) return false;
const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1];
const sum = weights.reduce((acc, w, i) => acc + w * parseInt(v[i], 10), 0);
return (sum % 10) === parseInt(v[12], 10);
}
function SectionLabel({ children }: { children: string }) {
return (
<Box style={{ borderLeft: `3px solid ${teal}`, paddingLeft: 10, marginTop: 20, marginBottom: 12 }}>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: teal }}>
{children}
</Text>
</Box>
);
}
interface Props {
opened: boolean;
onClose: () => void;
employeeId?: string;
}
export function EmployeeDrawer({ opened, onClose, employeeId }: Props) {
const isEdit = !!employeeId;
const qc = useQueryClient();
const { data: existing, isLoading: loadingExisting } = useQuery({
queryKey: ['employee', employeeId],
queryFn: () => apiClient.get<Employee>(`/employees/${employeeId}`).then((r) => r.data),
enabled: isEdit && opened,
staleTime: 30_000,
});
const { data: grades } = useQuery({
queryKey: ['ref', 'disability-grades'],
queryFn: () => apiClient.get<DisabilityGrade[]>('/reference/disability-grades').then((r) => r.data),
staleTime: 300_000,
});
const { data: employeesRes } = useQuery({
queryKey: ['employees', 'list-all'],
queryFn: () => apiClient.get<{items: Employee[]}>('/employees?limit=1000').then((r) => r.data),
staleTime: 60_000,
});
const allEmployees = employeesRes?.items ?? [];
const {
register,
handleSubmit,
control,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<EmployeeFormValues>({ resolver: zodResolver(employeeSchema) });
useEffect(() => {
if (existing && isEdit) {
reset({
idnp: existing.idnp,
nume: existing.nume,
prenume: existing.prenume,
patronimic: existing.patronimic ?? '',
numeAnterior: existing.numeAnterior ?? '',
dataNasterii: existing.dataNasterii.slice(0, 10),
domiciliu: existing.domiciliu,
adresaReala: existing.adresaReala ?? '',
telefonPersonal: existing.telefonPersonal,
telefonServiciu: existing.telefonServiciu ?? '',
emailPersonal: existing.emailPersonal ?? '',
emailCorporativ: existing.emailCorporativ ?? '',
sex: existing.sex,
stareCivila: existing.stareCivila ?? undefined,
codCpas: existing.codCpas ?? '',
gradDizabilitateId: existing.gradDizabilitateId ?? '',
recomandareInternaId: existing.recomandareInternaId ?? '',
titluStiintific: existing.titluStiintific ?? undefined,
titluUniversitar: existing.titluUniversitar ?? '',
status: existing.status ?? 'activ',
});
} else if (!isEdit) {
reset({});
}
}, [existing, isEdit, reset]);
const mutation = useMutation({
mutationFn: (data: EmployeeFormValues) => {
const payload = {
...data,
gradDizabilitateId: data.gradDizabilitateId || undefined,
recomandareInternaId: data.recomandareInternaId || undefined,
telefonServiciu: data.telefonServiciu || undefined,
emailPersonal: data.emailPersonal || undefined,
emailCorporativ: data.emailCorporativ || undefined,
patronimic: data.patronimic || undefined,
numeAnterior: data.numeAnterior || undefined,
adresaReala: data.adresaReala || undefined,
codCpas: data.codCpas || undefined,
titluUniversitar: data.titluUniversitar || undefined,
};
return isEdit
? apiClient.patch(`/employees/${employeeId}`, payload)
: apiClient.post('/employees', payload);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employees'] });
void qc.invalidateQueries({ queryKey: ['dashboard-stats'] });
if (isEdit) void qc.invalidateQueries({ queryKey: ['employee', employeeId] });
notifications.show({
color: 'medpark',
title: isEdit ? 'Salvat' : 'Angajat creat',
message: isEdit ? 'Modificările au fost salvate.' : 'Angajatul a fost adăugat.',
});
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare necunoscută';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const idnp = watch('idnp') ?? '';
const idnpOk = idnpValid(idnp);
const idnpIndicator = idnp.length === 0 ? null : idnpOk ? '✓' : '✗';
return (
<Drawer
opened={opened}
onClose={onClose}
title={
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '1rem', color: charcoal }}>
{isEdit ? 'Editează angajat' : 'Angajat nou'}
</Text>
}
position="right"
size="xl"
styles={{
header: { borderBottom: `2px solid ${teal}` },
body: { padding: '0 24px 24px' },
}}
>
{isEdit && loadingExisting ? (
<Box style={{ display: 'flex', justifyContent: 'center', paddingTop: 48 }}>
<Loader color="medpark" />
</Box>
) : (
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
{/* ── Date personale ── */}
<SectionLabel>Date personale</SectionLabel>
<Stack gap={12}>
<TextInput
label="IDNP *"
placeholder="1234567890123"
maxLength={13}
error={errors.idnp?.message}
rightSection={
idnpIndicator && (
<Text style={{ color: idnpOk ? teal : '#b11116', fontWeight: 700, fontSize: '1rem' }}>
{idnpIndicator}
</Text>
)
}
styles={{ input: { fontFamily: "'Courier New', monospace", letterSpacing: '0.1em' }, label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
{...register('idnp')}
/>
<Group grow>
<TextInput label="Nume *" error={errors.nume?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('nume')} />
<TextInput label="Prenume *" error={errors.prenume?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('prenume')} />
</Group>
<Group grow>
<TextInput label="Patronimic" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('patronimic')} />
<TextInput label="Nume anterior" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('numeAnterior')} />
</Group>
<Group grow align="flex-start">
<Controller
name="dataNasterii"
control={control}
render={({ field }) => (
<DateInput
label="Data nașterii *"
valueFormat="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
error={errors.dataNasterii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
<Box>
<Text size="xs" fw={500} mb={4} style={{ fontFamily: font }}>Sex *</Text>
<Controller
name="sex"
control={control}
render={({ field }) => (
<SegmentedControl
data={[{ value: 'F', label: 'Feminin' }, { value: 'M', label: 'Masculin' }]}
value={field.value ?? ''}
onChange={field.onChange}
color="medpark"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
{errors.sex && <Text size="xs" c="red" mt={4}>{errors.sex.message}</Text>}
</Box>
</Group>
<Controller
name="stareCivila"
control={control}
render={({ field }) => (
<Select
label="Stare civilă"
clearable
data={[
{ value: 'casatorit', label: 'Căsătorit' },
{ value: 'necasatorit', label: 'Necăsătorit' },
{ value: 'divortat', label: 'Divorțat' },
{ value: 'vaduv', label: 'Văduv' },
]}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? undefined)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
</Stack>
{/* ── Contact ── */}
<SectionLabel>Contact</SectionLabel>
<Stack gap={12}>
<TextInput label="Domiciliu *" error={errors.domiciliu?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('domiciliu')} />
<TextInput label="Adresă reală" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('adresaReala')} />
<Group grow>
<TextInput label="Telefon personal *" error={errors.telefonPersonal?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('telefonPersonal')} />
<TextInput label="Telefon serviciu" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('telefonServiciu')} />
</Group>
<Group grow>
<TextInput label="Email personal" error={errors.emailPersonal?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('emailPersonal')} />
<TextInput label="Email corporativ" error={errors.emailCorporativ?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('emailCorporativ')} />
</Group>
</Stack>
{/* ── Date suplimentare ── */}
<SectionLabel>Date suplimentare</SectionLabel>
<Stack gap={12}>
<TextInput label="Cod CPAS" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('codCpas')} />
<Controller
name="recomandareInternaId"
control={control}
render={({ field }) => (
<Select
label="Recomandare internă"
clearable
searchable
data={allEmployees
.filter(e => e.id !== employeeId) // prevent self-recommendation visually
.map((e) => ({ value: e.id, label: `${e.nume} ${e.prenume} (${e.idnp})` }))}
value={field.value || null}
onChange={(v) => field.onChange(v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
<Controller
name="gradDizabilitateId"
control={control}
render={({ field }) => (
<Select
label="Grad dizabilitate"
clearable
data={(grades ?? []).map((g) => ({ value: g.id, label: g.name }))}
value={field.value || null}
onChange={(v) => field.onChange(v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
<Controller
name="titluStiintific"
control={control}
render={({ field }) => (
<Select
label="Titlu științific"
clearable
data={[
{ value: 'doctor', label: 'Doctor' },
{ value: 'doctor_habilitat', label: 'Doctor habilitat' },
]}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? undefined)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
<TextInput label="Titlu universitar" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('titluUniversitar')} />
{isEdit && (
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
label="Status angajat"
data={[
{ value: 'activ', label: 'Activ' },
{ value: 'suspendat', label: 'Suspendat' },
{ value: 'concediat', label: 'Concediat' },
]}
value={field.value ?? 'activ'}
onChange={(v) => field.onChange(v ?? 'activ')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
/>
)}
</Stack>
{/* ── Actions ── */}
<Group justify="flex-end" mt={28} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
type="submit"
loading={isSubmitting}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}
>
Salvează
</Button>
</Group>
</form>
</Box>
)}
</Drawer>
);
}
@@ -0,0 +1,175 @@
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Group, Text, Box, Button, Stack } from '@mantine/core';
import { IconPhone, IconMail, IconBeach } from '@tabler/icons-react';
import { apiClient } from '../../../api/client';
import type { Employee } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const STATUS_CONFIG = {
activ: { color: teal, bg: '#e6f4f4', label: 'Activ' },
concediat: { color: '#b11116', bg: '#ffeaea', label: 'Concediat' },
suspendat: { color: '#f15a31', bg: '#fff3ee', label: 'Suspendat' },
};
interface Props {
employee: Employee;
onEdit: () => void;
}
export function EmployeeHeader({ employee, onEdit }: Props) {
const navigate = useNavigate();
const st = STATUS_CONFIG[employee.status];
const activeContract = employee.contracts.find((c) => !c.dataDemisiei);
const { data: zileMax } = useQuery({
queryKey: ['employee', employee.id, 'zile-concediu-max'],
queryFn: async () => {
const res = await apiClient.get<{ maxZileConcediu: number | null }>(
`/employees/${employee.id}/contracts/zile-concediu-max`,
);
return res.data.maxZileConcediu;
},
staleTime: 60_000,
});
return (
<Box
style={{
background: '#ffffff',
border: `1px solid ${border}`,
borderRadius: 8,
padding: '24px 28px',
borderLeft: `4px solid ${teal}`,
}}
>
<Group justify="space-between" align="flex-start">
<Stack gap={8}>
{/* Back link */}
<Text
size="xs"
c="#adb5bd"
style={{
fontFamily: font,
fontWeight: 500,
cursor: 'pointer',
letterSpacing: '0.02em',
}}
onClick={() => navigate('/employees')}
>
Angajați
</Text>
{/* Name */}
<Group gap={12} align="center">
<Text
style={{
fontFamily: font,
fontWeight: 700,
fontSize: '1.5rem',
color: charcoal,
lineHeight: 1.2,
}}
>
{employee.nume} {employee.prenume}
{employee.patronimic && (
<Text component="span" style={{ fontFamily: font, fontWeight: 300, fontSize: '1.25rem', color: '#6c757d' }}>
{' '}{employee.patronimic}
</Text>
)}
</Text>
{/* Status badge */}
<Box
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: 20,
background: st.bg,
color: st.color,
fontFamily: font,
fontWeight: 600,
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
{st.label}
</Box>
</Group>
{/* Meta row */}
<Group gap={20} mt={4}>
<Text size="sm" c="#6c757d" style={{ fontFamily: "'Courier New', monospace", letterSpacing: '0.04em' }}>
{employee.idnp}
</Text>
{employee.sex && (
<Text size="sm" c="#6c757d" style={{ fontFamily: font, fontWeight: 300 }}>
{employee.sex === 'F' ? 'Feminin' : 'Masculin'}
</Text>
)}
{activeContract?.functiaOrganigrama && (
<Text size="sm" style={{ fontFamily: font, fontWeight: 500, color: charcoal }}>
{activeContract.functiaOrganigrama}
</Text>
)}
{activeContract?.department && (
<Text size="sm" c="#6c757d" style={{ fontFamily: font, fontWeight: 300 }}>
{activeContract.department.name}
</Text>
)}
{zileMax != null && zileMax > 0 && (
<Group gap={4} align="center" title="Zile de concediu — MAX dintre toate CIM">
<IconBeach size={14} color={teal} stroke={1.5} />
<Text size="sm" style={{ fontFamily: font, fontWeight: 500, color: teal }}>
{zileMax} zile concediu
</Text>
</Group>
)}
</Group>
{/* Contacts */}
<Group gap={16} mt={2}>
<Group gap={4} align="center">
<IconPhone size={14} color="#adb5bd" stroke={1.5} />
<Text size="sm" c="#6c757d" style={{ fontFamily: font, fontWeight: 300 }}>
{employee.telefonPersonal}
</Text>
</Group>
{employee.emailCorporativ && (
<Group gap={4} align="center">
<IconMail size={14} color="#adb5bd" stroke={1.5} />
<Text size="sm" c="#6c757d" style={{ fontFamily: font, fontWeight: 300 }}>
{employee.emailCorporativ}
</Text>
</Group>
)}
</Group>
</Stack>
<Button
variant="outline"
onClick={onEdit}
style={{
borderColor: teal,
color: teal,
fontFamily: font,
fontWeight: 500,
fontSize: '0.875rem',
}}
>
Editează
</Button>
</Group>
</Box>
);
}
@@ -0,0 +1,246 @@
import { useEffect } from 'react';
import { Drawer, TextInput, Checkbox, Select, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { apiClient } from '../../../api/client';
import type { Benefit, InventoryItem, InventoryItemType, PaginatedInventory } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
uniformaId: z.string().nullable().optional(),
halatId: z.string().nullable().optional(),
ciupiciId: z.string().nullable().optional(),
vestaId: z.string().nullable().optional(),
aparatTelefonId: z.string().nullable().optional(),
ticheteMasa: z.boolean(),
valoareTichet: z.string().optional(),
alimentatiePersonal: z.boolean(),
abonamentTel: z.string().optional(),
cardCompanie: z.string().optional(),
automobilServiciu: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; benefit: Benefit | null; opened: boolean; onClose: () => void }
function useInventoryByType(type: InventoryItemType) {
return useQuery({
queryKey: ['inventory', 'by-type', type],
queryFn: () =>
apiClient
.get<PaginatedInventory>(`/inventory?type=${type}&active=true&limit=200`)
.then((r) => r.data.items),
staleTime: 60_000,
});
}
function buildOptions(items: InventoryItem[] | undefined, currentId: string | null | undefined) {
return (items ?? []).map((it) => ({
value: it.id,
label: `${it.sku}${it.name}${it.size ? ` (${it.size})` : ''} · stoc: ${it.stockQty}`,
disabled: it.stockQty === 0 && it.id !== currentId,
}));
}
export function BenefitDrawer({ employeeId, benefit, opened, onClose }: Props) {
const qc = useQueryClient();
const uniforme = useInventoryByType('uniforma');
const halate = useInventoryByType('halat');
const ciupici = useInventoryByType('ciupici');
const veste = useInventoryByType('vesta');
const aparate = useInventoryByType('aparat_telefon');
const { register, handleSubmit, control, reset, watch, formState: { isSubmitting } } =
useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
uniformaId: null,
halatId: null,
ciupiciId: null,
vestaId: null,
aparatTelefonId: null,
ticheteMasa: false,
alimentatiePersonal: false,
},
});
useEffect(() => {
reset(benefit ? {
uniformaId: benefit.uniformaId,
halatId: benefit.halatId,
ciupiciId: benefit.ciupiciId,
vestaId: benefit.vestaId,
aparatTelefonId: benefit.aparatTelefonId,
ticheteMasa: benefit.ticheteMasa,
valoareTichet: benefit.valoareTichet ?? '',
alimentatiePersonal: benefit.alimentatiePersonal,
abonamentTel: benefit.abonamentTel ?? '',
cardCompanie: benefit.cardCompanie ?? '',
automobilServiciu: benefit.automobilServiciu ?? '',
} : {
uniformaId: null,
halatId: null,
ciupiciId: null,
vestaId: null,
aparatTelefonId: null,
ticheteMasa: false,
alimentatiePersonal: false,
});
}, [benefit, reset, opened]);
const ticheteMasa = watch('ticheteMasa');
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const payload = {
uniformaId: data.uniformaId || undefined,
halatId: data.halatId || undefined,
ciupiciId: data.ciupiciId || undefined,
vestaId: data.vestaId || undefined,
aparatTelefonId: data.aparatTelefonId || undefined,
ticheteMasa: data.ticheteMasa,
valoareTichet: data.valoareTichet || undefined,
alimentatiePersonal: data.alimentatiePersonal,
abonamentTel: data.abonamentTel || undefined,
cardCompanie: data.cardCompanie || undefined,
automobilServiciu: data.automobilServiciu || undefined,
};
return apiClient.post(`/employees/${employeeId}/benefit`, payload);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employee', employeeId] });
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({ color: 'medpark', title: 'Salvat', message: 'Beneficii actualizate.' });
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Nu s-a putut salva.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const selectStyles = { label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } };
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>Editează beneficii</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting || mutation.isPending} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={16}>
<Box>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: teal, marginBottom: 10 }}>
Vestimentație / Inventar
</Text>
<Stack gap={10}>
<Controller name="uniformaId" control={control} render={({ field }) => (
<Select
label="Uniformă"
placeholder="Selectează din inventar"
data={buildOptions(uniforme.data, field.value)}
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
searchable
clearable
nothingFoundMessage="Niciun articol disponibil"
styles={selectStyles}
/>
)} />
<Controller name="halatId" control={control} render={({ field }) => (
<Select
label="Halat"
placeholder="Selectează din inventar"
data={buildOptions(halate.data, field.value)}
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
searchable
clearable
nothingFoundMessage="Niciun articol disponibil"
styles={selectStyles}
/>
)} />
<Controller name="ciupiciId" control={control} render={({ field }) => (
<Select
label="Ciupici"
placeholder="Selectează din inventar"
data={buildOptions(ciupici.data, field.value)}
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
searchable
clearable
nothingFoundMessage="Niciun articol disponibil"
styles={selectStyles}
/>
)} />
<Controller name="vestaId" control={control} render={({ field }) => (
<Select
label="Vestă"
placeholder="Selectează din inventar"
data={buildOptions(veste.data, field.value)}
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
searchable
clearable
nothingFoundMessage="Niciun articol disponibil"
styles={selectStyles}
/>
)} />
</Stack>
</Box>
<Box>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: teal, marginBottom: 10 }}>Alimentație</Text>
<Stack gap={8}>
<Controller name="ticheteMasa" control={control} render={({ field }) => (
<Checkbox label="Tichete de masă" checked={field.value} onChange={field.onChange} color="medpark"
styles={{ label: { fontFamily: font, fontWeight: 400, fontSize: '0.875rem' } }} />
)} />
{ticheteMasa && (
<TextInput label="Valoare tichet (MDL)" styles={selectStyles} {...register('valoareTichet')} />
)}
<Controller name="alimentatiePersonal" control={control} render={({ field }) => (
<Checkbox label="Alimentație personal" checked={field.value} onChange={field.onChange} color="medpark"
styles={{ label: { fontFamily: font, fontWeight: 400, fontSize: '0.875rem' } }} />
)} />
</Stack>
</Box>
<Box>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: teal, marginBottom: 10 }}>Comunicații</Text>
<Stack gap={8}>
<TextInput label="Abonament telefon (MDL/lună)" styles={selectStyles} {...register('abonamentTel')} />
<Controller name="aparatTelefonId" control={control} render={({ field }) => (
<Select
label="Aparat telefon"
placeholder="Selectează din inventar"
data={buildOptions(aparate.data, field.value)}
value={field.value ?? null}
onChange={(v) => field.onChange(v)}
searchable
clearable
nothingFoundMessage="Niciun aparat disponibil"
styles={selectStyles}
/>
)} />
<TextInput label="Card companie" styles={selectStyles} {...register('cardCompanie')} />
<TextInput label="Automobil serviciu" styles={selectStyles} {...register('automobilServiciu')} />
</Stack>
</Box>
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting || mutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,426 @@
import { useEffect, useState } from 'react';
import {
Drawer, Button, Group, Stack, Text, LoadingOverlay, Box,
Select, TextInput, NumberInput, SegmentedControl, Divider, ActionIcon, Textarea,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { EmploymentContract, Department, WorkSchedule } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const schema = z.object({
nrCim: z.string().min(1, 'Câmp obligatoriu'),
categorie: z.enum(['principal', 'secundar']),
dataSemnarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataAngajarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataDemisiei: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
perioada: z.enum(['determinata', 'nedeterminata', 'replasare_temporara']),
dataTerminarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
functiaClasificator: z.string().optional(),
codFunctie: z.string().optional(),
functiaOrganigrama: z.string().optional(),
tipCim: z.enum(['de_baza', 'cumul']),
departmentId: z.string().uuid('Selectați departamentul'),
regimMunca: z.string().optional(),
tipSalarizare: z.enum(['fix', 'pe_ore', 'in_acord']).optional(),
workScheduleId: z.string().uuid().optional().or(z.literal('')),
zileConcediu: z.number().int().min(0).max(365).optional(),
});
type FormValues = z.infer<typeof schema>;
interface ServiceCatRow { categorieId: string; tipRemunerare: 'tarif' | 'procent'; sumaNeta: string; procent: string }
const emptyRow = (): ServiceCatRow => ({ categorieId: '', tipRemunerare: 'tarif', sumaNeta: '', procent: '' });
interface ClausaRow { titlu: string; text: string }
const emptyClausaRow = (): ClausaRow => ({ titlu: '', text: '' });
interface Props {
employeeId: string;
record?: EmploymentContract;
opened: boolean;
onClose: () => void;
}
export function ContractDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const [serviceCats, setServiceCats] = useState<ServiceCatRow[]>([]);
const [clauze, setClauze] = useState<ClausaRow[]>([]);
const { data: depts } = useQuery({
queryKey: ['ref', 'departments-flat'],
queryFn: () => apiClient.get<Department[]>('/reference/departments/flat').then((r) => r.data),
staleTime: 300_000,
});
const { data: schedules } = useQuery({
queryKey: ['ref', 'work-schedules'],
queryFn: () => apiClient.get<WorkSchedule[]>('/reference/work-schedules').then((r) => r.data),
staleTime: 300_000,
});
const { handleSubmit, control, reset, watch, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
const perioadaValue = watch('perioada');
useEffect(() => {
if (record) {
reset({
nrCim: record.nrCim,
categorie: record.categorie,
dataSemnarii: record.dataSemnarii.slice(0, 10),
dataAngajarii: record.dataAngajarii.slice(0, 10),
dataDemisiei: record.dataDemisiei?.slice(0, 10) ?? '',
perioada: record.perioada,
dataTerminarii: record.dataTerminarii?.slice(0, 10) ?? '',
functiaClasificator: record.functiaClasificator ?? '',
codFunctie: record.codFunctie ?? '',
functiaOrganigrama: record.functiaOrganigrama ?? '',
tipCim: record.tipCim,
departmentId: record.departmentId,
regimMunca: record.regimMunca ?? '',
tipSalarizare: record.tipSalarizare ?? undefined,
workScheduleId: record.workSchedule?.id ?? '',
zileConcediu: (record.salarizareDetails as { zileConcediu?: number } | null)?.zileConcediu ?? undefined,
});
setServiceCats(record.categoriiServicii.map((c) => ({
categorieId: c.categorieId,
tipRemunerare: c.tipRemunerare as 'tarif' | 'procent',
sumaNeta: c.sumaNeta?.toString() ?? '',
procent: c.procent?.toString() ?? '',
})));
const cl = (record.clausaAditionala as { clauze?: ClausaRow[] } | null)?.clauze;
setClauze(Array.isArray(cl) ? cl.map(c => ({ titlu: c.titlu ?? '', text: c.text ?? '' })) : []);
} else {
reset({
categorie: 'principal',
tipCim: 'de_baza',
perioada: 'nedeterminata',
dataSemnarii: dayjs().format('YYYY-MM-DD'),
dataAngajarii: dayjs().format('YYYY-MM-DD'),
});
setServiceCats([]);
setClauze([]);
}
}, [record, opened, reset]);
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const { zileConcediu, ...rest } = data;
const payload = {
...rest,
dataDemisiei: data.dataDemisiei || undefined,
dataTerminarii: data.dataTerminarii || undefined,
functiaClasificator: data.functiaClasificator || undefined,
codFunctie: data.codFunctie || undefined,
functiaOrganigrama: data.functiaOrganigrama || undefined,
regimMunca: data.regimMunca || undefined,
workScheduleId: data.workScheduleId || undefined,
salarizareDetails: zileConcediu != null ? { zileConcediu } : undefined,
clausaAditionala: clauze.filter(c => c.titlu.trim() || c.text.trim()).length
? { clauze: clauze.filter(c => c.titlu.trim() || c.text.trim()) }
: undefined,
categoriiServicii: serviceCats
.filter((r) => r.categorieId)
.map((r) => ({
categorieId: r.categorieId,
tipRemunerare: r.tipRemunerare,
sumaNeta: r.tipRemunerare === 'tarif' && r.sumaNeta ? Number(r.sumaNeta) : undefined,
procent: r.tipRemunerare === 'procent' && r.procent ? Number(r.procent) : undefined,
})),
};
return isEdit
? apiClient.patch(`/employees/${employeeId}/contracts/${record.id}`, payload)
: apiClient.post(`/employees/${employeeId}/contracts`, payload);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employee', employeeId] });
void qc.invalidateQueries({ queryKey: ['contracts'] });
notifications.show({ color: 'medpark', title: 'Salvat', message: isEdit ? 'Contract actualizat.' : 'Contract creat.' });
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const deptData = (depts ?? []).map((d) => ({ value: d.id, label: d.name }));
const scheduleData = [
{ value: '', label: '— Fără program fix —' },
...(schedules ?? []).map((s) => ({ value: s.id, label: s.name })),
];
const section = (label: string) => (
<Divider
label={<Text size="xs" fw={700} c={teal} style={{ fontFamily: font, letterSpacing: '0.06em', textTransform: 'uppercase' }}>{label}</Text>}
labelPosition="left" my={12}
/>
);
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="xl"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>{isEdit ? 'Editare contract' : 'Contract nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={isSubmitting || mutation.isPending} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={10} pt={8}>
{section('Date contract')}
<Group grow>
<Controller name="nrCim" control={control} render={({ field }) => (
<TextInput {...field} label="Nr. CIM *" placeholder="CIM-2024-001"
error={errors.nrCim?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="categorie" control={control} render={({ field }) => (
<Box>
<Text size="xs" fw={500} mb={4} style={{ fontFamily: font, color: charcoal }}>Categorie *</Text>
<SegmentedControl {...field} data={[{ value: 'principal', label: 'Principal' }, { value: 'secundar', label: 'Secundar' }]} color="teal" size="xs" fullWidth />
</Box>
)} />
</Group>
<Group grow>
<Controller name="tipCim" control={control} render={({ field }) => (
<Box>
<Text size="xs" fw={500} mb={4} style={{ fontFamily: font, color: charcoal }}>Tip CIM *</Text>
<SegmentedControl {...field} data={[{ value: 'de_baza', label: 'De bază' }, { value: 'cumul', label: 'Cumul' }]} color="teal" size="xs" fullWidth />
</Box>
)} />
<Controller name="perioada" control={control} render={({ field }) => (
<Select label="Perioadă *"
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
data={[
{ value: 'nedeterminata', label: 'Nedeterminată' },
{ value: 'determinata', label: 'Determinată' },
{ value: 'replasare_temporara', label: 'Înlocuire temporară' },
]}
error={errors.perioada?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Group grow>
<Controller name="dataSemnarii" control={control} render={({ field }) => (
<DateInput label="Data semnării *" valueFormat="DD.MM.YYYY"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
error={errors.dataSemnarii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="dataAngajarii" control={control} render={({ field }) => (
<DateInput label="Data angajării *" valueFormat="DD.MM.YYYY"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
error={errors.dataAngajarii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
{perioadaValue === 'determinata' && (
<Controller name="dataTerminarii" control={control} render={({ field }) => (
<DateInput label="Data terminării contractului" valueFormat="DD.MM.YYYY"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
)}
{section('Funcție și departament')}
<Controller name="departmentId" control={control} render={({ field }) => (
<Select label="Departament *" data={deptData} searchable
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
error={errors.departmentId?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Group grow>
<Controller name="functiaOrganigrama" control={control} render={({ field }) => (
<TextInput {...field} label="Funcția conform organigramei"
placeholder="ex. Medic specialist cardiolog"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="functiaClasificator" control={control} render={({ field }) => (
<TextInput {...field} label="Ocupație CORM"
placeholder="ex. 2212 — Medic specialist"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Group grow>
<Controller name="codFunctie" control={control} render={({ field }) => (
<TextInput {...field} label="Cod funcție"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="regimMunca" control={control} render={({ field }) => (
<TextInput {...field} label="Regimul muncii"
placeholder="ex. Timp complet, 8h/zi"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
{section('Salarizare și program')}
<Group grow>
<Controller name="tipSalarizare" control={control} render={({ field }) => (
<Select {...field} value={field.value ?? ''} label="Tipul salarizării"
data={[
{ value: '', label: '— Nedefinit —' },
{ value: 'fix', label: 'Salariu fix' },
{ value: 'pe_ore', label: 'Pe ore' },
{ value: 'in_acord', label: 'În acord' },
]}
onChange={(v) => field.onChange(v || undefined)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="workScheduleId" control={control} render={({ field }) => (
<Select label="Program de lucru" data={scheduleData}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Controller name="zileConcediu" control={control} render={({ field }) => (
<NumberInput
label="Zile concediu anual"
description="Implicit 28 zile. Categorii speciale (radiologie, chirurgie etc.) — 35 zile."
placeholder="28"
min={0}
max={365}
value={field.value ?? ''}
onChange={(v) => field.onChange(typeof v === 'number' ? v : undefined)}
error={errors.zileConcediu?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' }, description: { fontFamily: font } }}
style={{ maxWidth: 240 }}
/>
)} />
{section('Categorii servicii')}
<Stack gap={8}>
{serviceCats.map((row, i) => (
<Group key={i} gap={8} align="flex-end">
<TextInput
label={i === 0 ? 'Cod categorie' : ''}
placeholder="ex. C001"
value={row.categorieId}
onChange={(e) => setServiceCats((prev) => prev.map((r, j) => j === i ? { ...r, categorieId: e.currentTarget.value } : r))}
style={{ flex: 2 }}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<Select
label={i === 0 ? 'Tip' : ''}
data={[{ value: 'tarif', label: 'Tarif' }, { value: 'procent', label: 'Procent' }]}
value={row.tipRemunerare}
onChange={(v) => setServiceCats((prev) => prev.map((r, j) => j === i ? { ...r, tipRemunerare: (v ?? 'tarif') as 'tarif' | 'procent' } : r))}
style={{ flex: 1 }}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
{row.tipRemunerare === 'tarif' ? (
<TextInput
label={i === 0 ? 'Sumă (MDL)' : ''}
type="number"
value={row.sumaNeta}
onChange={(e) => setServiceCats((prev) => prev.map((r, j) => j === i ? { ...r, sumaNeta: e.currentTarget.value } : r))}
style={{ flex: 1 }}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
) : (
<TextInput
label={i === 0 ? 'Procent (%)' : ''}
type="number"
value={row.procent}
onChange={(e) => setServiceCats((prev) => prev.map((r, j) => j === i ? { ...r, procent: e.currentTarget.value } : r))}
style={{ flex: 1 }}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
<ActionIcon color="red" variant="subtle" size="sm" mb={2}
onClick={() => setServiceCats((prev) => prev.filter((_, j) => j !== i))}>
</ActionIcon>
</Group>
))}
<Button size="xs" variant="subtle" color="teal"
onClick={() => setServiceCats((prev) => [...prev, emptyRow()])}
style={{ fontFamily: font, alignSelf: 'flex-start' }}>
+ Adaugă categorie serviciu
</Button>
</Stack>
{section('Clauze adiționale')}
<Stack gap={10}>
{clauze.length === 0 && (
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontStyle: 'italic' }}>
Nicio clauză adițională. Folosiți butonul de mai jos pentru a adăuga (mobilitate, confidențialitate, formare profesională etc.).
</Text>
)}
{clauze.map((row, i) => (
<Box key={i} className="hrm-clauza-row" style={{
border: '1px solid #e9ecef', borderLeft: `3px solid ${teal}`,
borderRadius: 4, padding: '10px 12px', background: '#fafafa',
}}>
<Group gap={8} align="flex-start" wrap="nowrap">
<Stack gap={6} style={{ flex: 1 }}>
<TextInput
label={i === 0 ? 'Titlu clauză' : undefined}
placeholder="ex. Clauză de mobilitate"
value={row.titlu}
onChange={(e) => setClauze(prev => prev.map((r, j) => j === i ? { ...r, titlu: e.currentTarget.value } : r))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' }, input: { fontFamily: font } }}
/>
<Textarea
label={i === 0 ? 'Conținut' : undefined}
placeholder="Descrierea clauzei..."
autosize minRows={2} maxRows={6}
value={row.text}
onChange={(e) => setClauze(prev => prev.map((r, j) => j === i ? { ...r, text: e.currentTarget.value } : r))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' }, input: { fontFamily: font } }}
/>
</Stack>
<ActionIcon color="red" variant="subtle" size="sm" mt={i === 0 ? 24 : 0}
onClick={() => setClauze(prev => prev.filter((_, j) => j !== i))}
title="Elimină clauza">
</ActionIcon>
</Group>
</Box>
))}
<Button size="xs" variant="subtle" color="teal"
onClick={() => setClauze(prev => [...prev, emptyClausaRow()])}
style={{ fontFamily: font, alignSelf: 'flex-start' }}>
+ Adaugă clauză adițională
</Button>
</Stack>
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid #e9ecef` }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: charcoal }}>Anulează</Button>
<Button type="submit" loading={mutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
{isEdit ? 'Salvează' : 'Creează'}
</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,81 @@
import { useEffect } from 'react';
import { Drawer, Select, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { DisciplinarySanction } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
tip: z.enum(['avertisment', 'mustrare', 'mustrare_aspra']),
dataAplicarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Dată obligatorie'),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; record?: DisciplinarySanction; opened: boolean; onClose: () => void }
export function DisciplinarySanctionDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => {
reset(record
? { tip: record.tip, dataAplicarii: record.dataAplicarii.slice(0, 10) }
: {});
}, [record, reset, opened]);
const mutation = useMutation({
mutationFn: (data: FormValues) =>
isEdit
? apiClient.patch(`/employees/${employeeId}/disciplinary-sanctions/${record.id}`, data)
: apiClient.post(`/employees/${employeeId}/disciplinary-sanctions`, data),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Sancțiune actualizată.' }); onClose(); },
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="sm"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează sancțiune' : 'Sancțiune nouă'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<Controller name="tip" control={control} render={({ field }) => (
<Select label="Tip sancțiune *" data={[
{ value: 'avertisment', label: 'Avertisment' },
{ value: 'mustrare', label: 'Mustrare' },
{ value: 'mustrare_aspra', label: 'Mustrare aspră' },
]} value={field.value} onChange={field.onChange} error={errors.tip?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="dataAplicarii" control={control} render={({ field }) => (
<DateInput label="Data aplicării *" valueFormat="YYYY-MM-DD"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
error={errors.dataAplicarii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Text size="xs" c="#6c757d" style={{ fontFamily: font, fontWeight: 300 }}>
Data expirării se calculează automat: data aplicării + 6 luni.
</Text>
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,118 @@
import { useEffect } from 'react';
import { Drawer, Select, TextInput, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { Education } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
tipStudii: z.enum(['superioare', 'medii_de_specialitate', 'secundare_tehnice', 'medii']),
institutia: z.string().min(1, 'Câmp obligatoriu'),
specialitatea: z.string().min(1, 'Câmp obligatoriu'),
dataAbsolvirii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
nrSeriaDiploma: z.string().optional(),
dataEmiterii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
nrInregistrare: z.string().optional(),
confirmare: z.enum(['confirmata', 'neconfirmata']).optional(),
nivel: z.enum(['de_baza', 'postuniversitar']).optional(),
tipPostuniversitar: z.enum(['masterat', 'rezidentiat', 'secundariat', 'altele']).optional(),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; record?: Education; opened: boolean; onClose: () => void }
export function EducationDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { register, handleSubmit, control, reset, watch, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => {
reset(record ? {
tipStudii: record.tipStudii,
institutia: record.institutia,
specialitatea: record.specialitatea,
dataAbsolvirii: record.dataAbsolvirii?.slice(0, 10) ?? '',
nrSeriaDiploma: record.nrSeriaDiploma ?? undefined,
dataEmiterii: record.dataEmiterii?.slice(0, 10) ?? '',
nrInregistrare: record.nrInregistrare ?? undefined,
confirmare: record.confirmare ?? undefined,
nivel: record.nivel ?? undefined,
tipPostuniversitar: record.tipPostuniversitar ?? undefined,
} : {});
}, [record, reset, opened]);
const nivelValue = watch('nivel');
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const payload = { ...data, dataAbsolvirii: data.dataAbsolvirii || undefined, dataEmiterii: data.dataEmiterii || undefined };
return isEdit
? apiClient.patch(`/employees/${employeeId}/educations/${record.id}`, payload)
: apiClient.post(`/employees/${employeeId}/educations`, payload);
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Studii actualizate.' }); onClose(); },
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează studii' : 'Studii noi'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<Controller name="tipStudii" control={control} render={({ field }) => (
<Select label="Tip studii *" data={[
{ value: 'superioare', label: 'Superioare' }, { value: 'medii_de_specialitate', label: 'Medii de specialitate' },
{ value: 'secundare_tehnice', label: 'Secundare tehnice' }, { value: 'medii', label: 'Medii' },
]} value={field.value} onChange={field.onChange} error={errors.tipStudii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Instituția *" error={errors.institutia?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('institutia')} />
<TextInput label="Specialitatea *" error={errors.specialitatea?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('specialitatea')} />
<Group grow>
<Controller name="dataAbsolvirii" control={control} render={({ field }) => (
<DateInput label="Data absolvirii" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Nr./Seria diplomă" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('nrSeriaDiploma')} />
</Group>
<Group grow>
<Controller name="confirmare" control={control} render={({ field }) => (
<Select label="Confirmare" clearable data={[{ value: 'confirmata', label: 'Confirmată' }, { value: 'neconfirmata', label: 'Neconfirmată' }]}
value={field.value ?? null} onChange={(v) => field.onChange(v ?? undefined)} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="nivel" control={control} render={({ field }) => (
<Select label="Nivel" clearable data={[{ value: 'de_baza', label: 'De bază' }, { value: 'postuniversitar', label: 'Postuniversitar' }]}
value={field.value ?? null} onChange={(v) => field.onChange(v ?? undefined)} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
{nivelValue === 'postuniversitar' && (
<Controller name="tipPostuniversitar" control={control} render={({ field }) => (
<Select label="Tip postuniversitar" clearable data={[
{ value: 'masterat', label: 'Masterat' }, { value: 'rezidentiat', label: 'Rezidențiat' },
{ value: 'secundariat', label: 'Secundariat' }, { value: 'altele', label: 'Altele' },
]} value={field.value ?? null} onChange={(v) => field.onChange(v ?? undefined)} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
)}
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,108 @@
import { useEffect } from 'react';
import { Drawer, Select, TextInput, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { FamilyMember, TaxExemption } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
tip: z.enum(['contact_principal', 'sot', 'sotie', 'mama', 'tata', 'copil']),
numePrenume: z.string().min(1, 'Câmp obligatoriu'),
dataNasterii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
idnp: z.string().length(13).optional().or(z.literal('')),
telefon: z.string().optional(),
tipScutireId: z.string().uuid().optional().or(z.literal('')),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; record?: FamilyMember; opened: boolean; onClose: () => void }
export function FamilyMemberDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { data: exemptions } = useQuery({
queryKey: ['ref', 'tax-exemptions'],
queryFn: () => apiClient.get<TaxExemption[]>('/reference/tax-exemptions').then((r) => r.data),
staleTime: 300_000,
});
const { register, handleSubmit, control, reset, watch, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => {
reset(record
? {
tip: record.tip,
numePrenume: record.numePrenume,
dataNasterii: record.dataNasterii?.slice(0, 10) ?? '',
idnp: record.idnp ?? '',
telefon: record.telefon ?? undefined,
tipScutireId: record.tipScutireId ?? '',
}
: {});
}, [record, reset, opened]);
const tipValue = watch('tip');
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const payload = { ...data, dataNasterii: data.dataNasterii || undefined, idnp: data.idnp || undefined, tipScutireId: data.tipScutireId || undefined };
return isEdit
? apiClient.patch(`/employees/${employeeId}/family-members/${record.id}`, payload)
: apiClient.post(`/employees/${employeeId}/family-members`, payload);
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Modificat.' }); onClose(); },
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează membru' : 'Membru nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<Controller name="tip" control={control} render={({ field }) => (
<Select label="Tip *" data={[
{ value: 'contact_principal', label: 'Contact principal' }, { value: 'sot', label: 'Soț' },
{ value: 'sotie', label: 'Soție' }, { value: 'mama', label: 'Mamă' },
{ value: 'tata', label: 'Tată' }, { value: 'copil', label: 'Copil' },
]} value={field.value} onChange={field.onChange} error={errors.tip?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Nume prenume *" error={errors.numePrenume?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('numePrenume')} />
<TextInput label="IDNP" maxLength={13} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('idnp')} />
<Controller name="dataNasterii" control={control} render={({ field }) => (
<DateInput label="Data nașterii" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Telefon" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('telefon')} />
{tipValue === 'copil' && (
<Controller name="tipScutireId" control={control} render={({ field }) => (
<Select label="Scutire fiscală (IRS)" clearable
data={(exemptions ?? []).map((e) => ({ value: e.id, label: `${e.code}${e.description}` }))}
value={field.value || null} onChange={(v) => field.onChange(v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
)}
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { Drawer, Select, TextInput, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { IdentityDocument } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
tipAct: z.enum(['buletin_de_identitate', 'pasaport']),
seria: z.string().optional(),
nr: z.string().min(1, 'Câmp obligatoriu'),
dataEmiterii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
autoritateEmitenta: z.string().min(1, 'Câmp obligatoriu'),
dataExpirarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
type FormValues = z.infer<typeof schema>;
interface Props {
employeeId: string;
record?: IdentityDocument;
opened: boolean;
onClose: () => void;
}
export function IdentityDocumentDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { register, handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => {
reset(record
? {
tipAct: record.tipAct,
seria: record.seria ?? undefined,
nr: record.nr,
dataEmiterii: record.dataEmiterii.slice(0, 10),
autoritateEmitenta: record.autoritateEmitenta,
dataExpirarii: record.dataExpirarii.slice(0, 10),
}
: { tipAct: 'buletin_de_identitate' });
}, [record, reset, opened]);
const mutation = useMutation({
mutationFn: (data: FormValues) =>
isEdit
? apiClient.patch(`/employees/${employeeId}/identity-documents/${record.id}`, data)
: apiClient.post(`/employees/${employeeId}/identity-documents`, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['employee', employeeId] });
notifications.show({ color: 'medpark', title: 'Salvat', message: 'Document actualizat.' });
onClose();
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează document' : 'Document nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<Controller name="tipAct" control={control} render={({ field }) => (
<Select label="Tip act *" data={[{ value: 'buletin_de_identitate', label: 'Buletin de identitate' }, { value: 'pasaport', label: 'Pașaport' }]}
value={field.value} onChange={field.onChange} error={errors.tipAct?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Group grow>
<TextInput label="Seria" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('seria')} />
<TextInput label="Nr. *" error={errors.nr?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('nr')} />
</Group>
<Controller name="dataEmiterii" control={control} render={({ field }) => (
<DateInput label="Data emiterii *" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} error={errors.dataEmiterii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Autoritate emitentă *" error={errors.autoritateEmitenta?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('autoritateEmitenta')} />
<Controller name="dataExpirarii" control={control} render={({ field }) => (
<DateInput label="Data expirării *" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} error={errors.dataExpirarii?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,92 @@
import { useEffect } from 'react';
import { Drawer, Select, TextInput, Button, Group, Stack, Text, LoadingOverlay, Box } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { Qualification } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
categorie: z.enum(['fara', 'cat_II', 'cat_I', 'superioara']),
specialitate: z.string().optional(),
dataObtinerii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
dataUltimeiConfirmari: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
dataExpirarii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; record?: Qualification; opened: boolean; onClose: () => void }
export function QualificationDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { register, handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => {
reset(record ? {
categorie: record.categorie,
specialitate: record.specialitate ?? '',
dataObtinerii: record.dataObtinerii?.slice(0, 10) ?? '',
dataUltimeiConfirmari: record.dataUltimeiConfirmari?.slice(0, 10) ?? '',
dataExpirarii: record.dataExpirarii?.slice(0, 10) ?? '',
} : {});
}, [record, reset, opened]);
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const payload = { ...data, dataObtinerii: data.dataObtinerii || undefined, dataUltimeiConfirmari: data.dataUltimeiConfirmari || undefined, dataExpirarii: data.dataExpirarii || undefined };
return isEdit
? apiClient.patch(`/employees/${employeeId}/qualifications/${record.id}`, payload)
: apiClient.post(`/employees/${employeeId}/qualifications`, payload);
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Calificare actualizată.' }); onClose(); },
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează calificare' : 'Calificare nouă'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<Controller name="categorie" control={control} render={({ field }) => (
<Select label="Categorie *" data={[
{ value: 'fara', label: 'Fără categorie' }, { value: 'cat_II', label: 'Categoria II' },
{ value: 'cat_I', label: 'Categoria I' }, { value: 'superioara', label: 'Superioară' },
]} value={field.value} onChange={field.onChange} error={errors.categorie?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<TextInput label="Specialitate" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('specialitate')} />
<Controller name="dataObtinerii" control={control} render={({ field }) => (
<DateInput label="Data obținerii" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="dataUltimeiConfirmari" control={control} render={({ field }) => (
<DateInput label="Ultima confirmare" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="dataExpirarii" control={control} render={({ field }) => (
<DateInput label="Data expirării" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,113 @@
import { useEffect } from 'react';
import { Drawer, Select, TextInput, Checkbox, Button, Group, Stack, Text, LoadingOverlay, Box, NumberInput } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type { Training } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const schema = z.object({
denumire: z.string().min(1, 'Câmp obligatoriu'),
inceput: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
sfirsit: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
tip: z.enum(['orientare', 'intern', 'extern_RM', 'extern_international']),
tara: z.string().optional(),
nrOre: z.number().int().positive().optional(),
organizatia: z.string().optional(),
certificat: z.boolean(),
cost: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
interface Props { employeeId: string; record?: Training; opened: boolean; onClose: () => void }
export function TrainingDrawer({ employeeId, record, opened, onClose }: Props) {
const isEdit = !!record;
const qc = useQueryClient();
const { register, handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { certificat: false } });
useEffect(() => {
reset(record ? {
denumire: record.denumire,
inceput: record.inceput.slice(0, 10),
sfirsit: record.sfirsit?.slice(0, 10) ?? '',
tip: record.tip,
tara: record.tara ?? '',
nrOre: record.nrOre ?? undefined,
organizatia: record.organizatia ?? '',
certificat: record.certificat,
cost: record.cost ?? '',
} : { certificat: false });
}, [record, reset, opened]);
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const payload = { ...data, sfirsit: data.sfirsit || undefined, tara: data.tara || undefined, organizatia: data.organizatia || undefined, cost: data.cost || undefined };
return isEdit
? apiClient.patch(`/employees/${employeeId}/trainings/${record.id}`, payload)
: apiClient.post(`/employees/${employeeId}/trainings`, payload);
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['employee', employeeId] }); notifications.show({ color: 'medpark', title: 'Salvat', message: 'Training actualizat.' }); onClose(); },
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
return (
<Drawer opened={opened} onClose={onClose} position="right" size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#58595b' }}>{isEdit ? 'Editează training' : 'Training nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` }, body: { padding: '16px 24px 24px' } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={12}>
<TextInput label="Denumire *" error={errors.denumire?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('denumire')} />
<Controller name="tip" control={control} render={({ field }) => (
<Select label="Tip *" data={[
{ value: 'orientare', label: 'Orientare' }, { value: 'intern', label: 'Intern' },
{ value: 'extern_RM', label: 'Extern (RM)' }, { value: 'extern_international', label: 'Extern internațional' },
]} value={field.value} onChange={field.onChange} error={errors.tip?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Group grow>
<Controller name="inceput" control={control} render={({ field }) => (
<DateInput label="Început *" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')} error={errors.inceput?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="sfirsit" control={control} render={({ field }) => (
<DateInput label="Sfârșit" valueFormat="YYYY-MM-DD" value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).format('YYYY-MM-DD') : '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Group grow>
<TextInput label="Țara" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('tara')} />
<Controller name="nrOre" control={control} render={({ field }) => (
<NumberInput label="Nr. ore" value={field.value ?? ''} onChange={(v) => field.onChange(v === '' ? undefined : v)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<TextInput label="Organizație" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('organizatia')} />
<TextInput label="Cost (MDL)" styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('cost')} />
<Controller name="certificat" control={control} render={({ field }) => (
<Checkbox label="Certificat obținut" checked={field.value} onChange={field.onChange}
styles={{ label: { fontFamily: font, fontWeight: 400, fontSize: '0.875rem' } }} color="medpark" />
)} />
</Stack>
<Group justify="flex-end" mt={24} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: '#58595b' }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Salvează</Button>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,35 @@
import { z } from 'zod';
function validateIdnp(idnp: string): boolean {
if (!/^\d{13}$/.test(idnp)) return false;
const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1];
const sum = weights.reduce((acc, w, i) => acc + w * parseInt(idnp[i], 10), 0);
return (sum % 10) === parseInt(idnp[12], 10);
}
const phoneRegex = /^\+?[0-9\s\-()\d]{7,20}$/;
export const employeeSchema = z.object({
idnp: z.string().refine(validateIdnp, { message: 'IDNP invalid (13 cifre, cifra de control incorectă)' }),
nume: z.string().min(1, 'Câmp obligatoriu').max(100),
prenume: z.string().min(1, 'Câmp obligatoriu').max(100),
patronimic: z.string().optional(),
numeAnterior: z.string().optional(),
dataNasterii: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format: YYYY-MM-DD'),
domiciliu: z.string().min(1, 'Câmp obligatoriu'),
adresaReala: z.string().optional(),
telefonPersonal: z.string().regex(phoneRegex, 'Număr telefon invalid'),
telefonServiciu: z.string().regex(phoneRegex, 'Număr telefon invalid').optional().or(z.literal('')),
emailPersonal: z.string().email('Email invalid').optional().or(z.literal('')),
emailCorporativ: z.string().email('Email invalid').optional().or(z.literal('')),
sex: z.enum(['F', 'M']),
stareCivila: z.enum(['casatorit', 'necasatorit', 'divortat', 'vaduv']).optional(),
codCpas: z.string().optional(),
gradDizabilitateId: z.string().uuid().optional().or(z.literal('')),
recomandareInternaId: z.string().uuid().optional().or(z.literal('')),
titluStiintific: z.enum(['doctor', 'doctor_habilitat']).optional(),
titluUniversitar: z.string().optional(),
status: z.enum(['activ', 'concediat', 'suspendat']).optional(),
});
export type EmployeeFormValues = z.infer<typeof employeeSchema>;
@@ -0,0 +1,87 @@
import { Box, Button, SimpleGrid, Text } from '@mantine/core';
import type { Benefit } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
function BField({ label, value }: { label: string; value?: string | boolean | null }) {
const display =
value === true ? 'Da' :
value === false ? 'Nu' :
(value as string | null | undefined) ?? '—';
const isYes = value === true || value === 'Da';
return (
<Box style={{ padding: '10px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text size="xs" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#adb5bd', marginBottom: 2 }}>
{label}
</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: isYes ? 500 : 300, color: isYes ? teal : charcoal }}>
{display}
</Text>
</Box>
);
}
function SectionTitle({ children }: { children: string }) {
return (
<Box mb={4} mt={16}>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: teal }}>
{children}
</Text>
<Box style={{ height: 2, background: teal, width: 32, borderRadius: 1, marginTop: 4 }} />
</Box>
);
}
interface Props {
benefit: Benefit | null;
onEdit: () => void;
}
export function BeneficiiTab({ benefit, onEdit }: Props) {
return (
<Box>
<Box style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Button size="xs" variant="outline" onClick={onEdit} style={{ borderColor: teal, color: teal, fontFamily: font, fontWeight: 500 }}>
Editează beneficii
</Button>
</Box>
{!benefit ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Beneficiile nu sunt configurate
</Text>
) : (
<SimpleGrid cols={2} spacing={32}>
<Box>
<SectionTitle>Vestimentație / Inventar</SectionTitle>
<BField label="Uniformă" value={benefit.uniforma ? `${benefit.uniforma.sku}${benefit.uniforma.name}` : null} />
<BField label="Halat" value={benefit.halat ? `${benefit.halat.sku}${benefit.halat.name}` : null} />
<BField label="Ciupici" value={benefit.ciupici ? `${benefit.ciupici.sku}${benefit.ciupici.name}` : null} />
<BField label="Vestă" value={benefit.vesta ? `${benefit.vesta.sku}${benefit.vesta.name}` : null} />
</Box>
<Box>
<SectionTitle>Alimentație</SectionTitle>
<BField label="Tichete de masă" value={benefit.ticheteMasa} />
{benefit.ticheteMasa && (
<BField label="Valoare tichet (MDL)" value={benefit.valoareTichet} />
)}
<BField label="Alimentație personal" value={benefit.alimentatiePersonal} />
</Box>
<Box>
<SectionTitle>Comunicații</SectionTitle>
<BField label="Abonament telefon (MDL/lună)" value={benefit.abonamentTel} />
<BField label="Aparat telefon" value={benefit.aparatTelefon ? `${benefit.aparatTelefon.sku}${benefit.aparatTelefon.name}` : null} />
<BField label="Card companie" value={benefit.cardCompanie} />
<BField label="Automobil serviciu" value={benefit.automobilServiciu} />
</Box>
</SimpleGrid>
)}
</Box>
);
}
@@ -0,0 +1,96 @@
import { Box, Button, Text, Group } from '@mantine/core';
import dayjs from 'dayjs';
import type { Qualification } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const CAT_LABELS: Record<string, string> = {
fara: 'Fără categorie',
cat_II: 'Categoria II',
cat_I: 'Categoria I',
superioara: 'Superioară',
};
function expiryStyle(dateStr: string | null): React.CSSProperties {
if (!dateStr) return {};
const days = dayjs(dateStr).diff(dayjs(), 'day');
if (days < 0) return { color: '#b11116', fontWeight: 600 };
if (days < 30) return { color: '#f15a31', fontWeight: 600 };
if (days < 90) return { color: '#fbb034', fontWeight: 500 };
return {};
}
interface Props {
qualifications: Qualification[];
onAdd: () => void;
onEdit: (q: Qualification) => void;
}
export function CalificariTab({ qualifications, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ Adaugă calificare
</Button>
</Group>
{qualifications.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Nicio calificare înregistrată
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Categorie', 'Specialitate', 'Data obținerii', 'Ultima confirmare', 'Expiră', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{qualifications.map((q) => (
<tr key={q.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px' }}>
<Box style={{
display: 'inline-flex', padding: '2px 8px', borderRadius: 20,
background: '#e6f4f4', color: teal,
fontFamily: font, fontWeight: 600, fontSize: '0.75rem',
}}>
{CAT_LABELS[q.categorie] ?? q.categorie}
</Box>
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{q.specialitate ?? '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{q.dataObtinerii ? dayjs(q.dataObtinerii).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{q.dataUltimeiConfirmari ? dayjs(q.dataUltimeiConfirmari).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontSize: '0.875rem', ...expiryStyle(q.dataExpirarii) }}>
{q.dataExpirarii ? dayjs(q.dataExpirarii).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }} onClick={() => onEdit(q)}>
Editează
</Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,195 @@
import { Box, Button, Text, Group, Badge, ActionIcon, Tooltip } from '@mantine/core';
import { IconPencil, IconPlayerStop, IconTrash } from '@tabler/icons-react';
import dayjs from 'dayjs';
import type { EmploymentContract } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const border = '#e9ecef';
const PERIOADA_LABEL: Record<string, string> = {
determinata: 'Determinată',
nedeterminata: 'Nedeterminată',
replasare_temporara: 'Înlocuire temporară',
};
const TIP_CIM_LABEL: Record<string, string> = {
de_baza: 'De bază',
cumul: 'Cumul',
};
const CATEGORIE_LABEL: Record<string, string> = {
principal: 'Principal',
secundar: 'Secundar',
};
interface Props {
contracts: EmploymentContract[];
onAdd: () => void;
onEdit: (c: EmploymentContract) => void;
onTerminate: (c: EmploymentContract) => void;
onDelete: (c: EmploymentContract) => void;
}
export function ContracteTab({ contracts, onAdd, onEdit, onTerminate, onDelete }: Props) {
const active = contracts.filter((c) => !c.dataDemisiei);
const inactive = contracts.filter((c) => !!c.dataDemisiei);
return (
<Box>
<Group justify="space-between" mb={16}>
<Text style={{ fontFamily: font, fontWeight: 600, color: charcoal }}>
Contracte individuale de muncă
</Text>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ CIM nou
</Button>
</Group>
{!contracts.length ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Niciun contract înregistrat.
</Text>
) : (
<>
{active.map((c) => <ContractCard key={c.id} contract={c} onEdit={onEdit} onTerminate={onTerminate} onDelete={onDelete} />)}
{inactive.length > 0 && (
<>
<Text size="xs" c="#adb5bd" mt={20} mb={8} style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.07em' }}>
Contracte încheiate
</Text>
{inactive.map((c) => <ContractCard key={c.id} contract={c} onEdit={onEdit} onTerminate={onTerminate} onDelete={onDelete} inactive />)}
</>
)}
</>
)}
</Box>
);
}
function ContractCard({
contract: c,
onEdit,
onTerminate,
onDelete,
inactive,
}: {
contract: EmploymentContract;
onEdit: (c: EmploymentContract) => void;
onTerminate: (c: EmploymentContract) => void;
onDelete: (c: EmploymentContract) => void;
inactive?: boolean;
}) {
return (
<Box
mb={12}
style={{
border: `1px solid ${inactive ? '#e9ecef' : teal + '44'}`,
borderLeft: `4px solid ${inactive ? '#adb5bd' : teal}`,
borderRadius: 6,
padding: '14px 16px',
background: inactive ? '#fafafa' : '#f0fafa',
opacity: inactive ? 0.8 : 1,
}}
>
<Group justify="space-between" align="flex-start">
<Box>
<Group gap={8} mb={6}>
<Text style={{ fontFamily: font, fontWeight: 700, color: inactive ? charcoal : teal, fontSize: '0.95rem' }}>
CIM {c.nrCim}
</Text>
<Badge size="xs" color={inactive ? 'gray' : 'teal'} variant={inactive ? 'outline' : 'light'} style={{ fontFamily: font }}>
{inactive ? 'Încetat' : 'Activ'}
</Badge>
<Badge size="xs" color="gray" variant="outline" style={{ fontFamily: font }}>
{CATEGORIE_LABEL[c.categorie]}
</Badge>
<Badge size="xs" color="gray" variant="outline" style={{ fontFamily: font }}>
{TIP_CIM_LABEL[c.tipCim]}
</Badge>
</Group>
<Group gap={24} wrap="wrap">
<InfoItem label="Departament" value={c.department?.name ?? '—'} highlight={!inactive} />
<InfoItem label="Funcție (organigr.)" value={c.functiaOrganigrama ?? '—'} />
<InfoItem label="Funcție (clasificator)" value={c.functiaClasificator ?? '—'} />
<InfoItem label="Angajat la" value={dayjs(c.dataAngajarii).format('DD.MM.YYYY')} />
<InfoItem label="Semnat la" value={dayjs(c.dataSemnarii).format('DD.MM.YYYY')} />
<InfoItem label="Perioadă" value={PERIOADA_LABEL[c.perioada]} />
{c.dataTerminarii && (
<InfoItem label="Expiră la" value={dayjs(c.dataTerminarii).format('DD.MM.YYYY')} />
)}
{c.dataDemisiei && (
<InfoItem label="Data demisiei" value={dayjs(c.dataDemisiei).format('DD.MM.YYYY')} />
)}
{c.tipSalarizare && (
<InfoItem label="Salarizare" value={
c.tipSalarizare === 'fix' ? 'Salariu fix' :
c.tipSalarizare === 'pe_ore' ? 'Pe ore' : 'În acord'
} />
)}
{c.workSchedule && (
<InfoItem label="Program lucru" value={c.workSchedule.name} />
)}
</Group>
</Box>
<Group gap={6}>
<Tooltip label="Editează">
<ActionIcon variant="subtle" color="teal" size="sm" onClick={() => onEdit(c)}>
<IconPencil size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
{!inactive && (
<Tooltip label="Încetare contract">
<ActionIcon variant="subtle" color="orange" size="sm" onClick={() => onTerminate(c)}>
<IconPlayerStop size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
)}
{inactive && (
<Tooltip label="Șterge contract">
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(c)}>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{c.categoriiServicii.length > 0 && (
<Box mt={10} pt={10} style={{ borderTop: `1px solid ${border}` }}>
<Text size="xs" c="#adb5bd" mb={4} style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Categorii servicii
</Text>
<Group gap={6} wrap="wrap">
{c.categoriiServicii.map((cat) => (
<Box key={cat.id} style={{
background: '#e6f4f4', borderRadius: 4, padding: '2px 8px',
fontFamily: font, fontWeight: 500, fontSize: '0.75rem', color: teal,
}}>
{cat.categorieId}
{cat.tipRemunerare === 'tarif' && cat.sumaNeta != null && `${Number(cat.sumaNeta).toFixed(0)} MDL`}
{cat.tipRemunerare === 'procent' && cat.procent != null && `${Number(cat.procent).toFixed(0)}%`}
</Box>
))}
</Group>
</Box>
)}
</Box>
);
}
function InfoItem({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<Box>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', fontSize: '0.62rem' }}>
{label}
</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: highlight ? 600 : 400, color: highlight ? teal : charcoal }}>
{value}
</Text>
</Box>
);
}
@@ -0,0 +1,96 @@
import { Box, Button, Text, Group } from '@mantine/core';
import dayjs from 'dayjs';
import type { IdentityDocument } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const DOC_LABELS: Record<string, string> = {
buletin_de_identitate: 'Buletin de identitate',
pasaport: 'Pașaport',
};
function expiryStyle(dateStr: string): React.CSSProperties {
const days = dayjs(dateStr).diff(dayjs(), 'day');
if (days < 0) return { background: '#ffeaea', color: '#b11116', fontWeight: 600 };
if (days < 30) return { background: '#fff8e6', color: '#f15a31', fontWeight: 600 };
return {};
}
interface Props {
documents: IdentityDocument[];
employeeId: string;
onAdd: () => void;
onEdit: (doc: IdentityDocument) => void;
}
export function DocumenteTab({ documents, employeeId: _employeeId, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button
size="xs"
onClick={onAdd}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}
>
+ Adaugă document
</Button>
</Group>
{documents.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Niciun document de identitate
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Tip act', 'Seria / Nr.', 'Data emiterii', 'Autoritate emitentă', 'Data expirării', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 400, fontSize: '0.875rem', color: charcoal }}>
{DOC_LABELS[doc.tipAct] ?? doc.tipAct}
</td>
<td style={{ padding: '11px 14px', fontFamily: "'Courier New', monospace", fontSize: '0.8rem', color: '#6c757d' }}>
{doc.seria ? `${doc.seria} ` : ''}{doc.nr}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{dayjs(doc.dataEmiterii).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{doc.autoritateEmitenta}
</td>
<td style={{ padding: '11px 14px', fontSize: '0.875rem', ...expiryStyle(doc.dataExpirarii) }}>
{dayjs(doc.dataExpirarii).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text
size="xs"
c={teal}
style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }}
onClick={() => onEdit(doc)}
>
Editează
</Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,86 @@
import { Box, Button, Text, Group } from '@mantine/core';
import dayjs from 'dayjs';
import type { FamilyMember } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const TIP_LABELS: Record<string, string> = {
contact_principal: 'Contact principal',
sot: 'Soț',
sotie: 'Soție',
mama: 'Mamă',
tata: 'Tată',
copil: 'Copil',
};
interface Props {
members: FamilyMember[];
onAdd: () => void;
onEdit: (m: FamilyMember) => void;
}
export function FamilieTab({ members, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ Adaugă membru
</Button>
</Group>
{members.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Niciun membru de familie înregistrat
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Tip', 'Nume prenume', 'IDNP', 'Data nașterii', 'Telefon', 'Scutire fiscală', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: teal }}>
{TIP_LABELS[m.tip] ?? m.tip}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 400, fontSize: '0.875rem', color: charcoal }}>
{m.numePrenume}
</td>
<td style={{ padding: '11px 14px', fontFamily: "'Courier New', monospace", fontSize: '0.8rem', color: '#6c757d' }}>
{m.idnp ?? '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{m.dataNasterii ? dayjs(m.dataNasterii).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{m.telefon ?? '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{m.tipScutire?.description ?? '—'}
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }} onClick={() => onEdit(m)}>
Editează
</Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,659 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Box, Button, Text, Group, Stack, Paper, Select, TextInput,
Switch, NumberInput, Loader, Center, Badge, Modal,
LoadingOverlay, ActionIcon, Tooltip,
} from '@mantine/core';
import { IconRadioactive, IconDownload, IconTrash, IconPlus } from '@tabler/icons-react';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../../api/client';
import type {
EmployeeMedicalProfile, MedicalCheckup, WorkplaceRiskCard,
MedicalVerdict, GeneratedDoc, RadiationOverexposure, OverexposureKind,
} from '../../../api/types';
const OVEREXPOSURE_KINDS: { value: OverexposureKind; label: string }[] = [
{ value: 'EXCEPTIONALA', label: 'Excepțională' },
{ value: 'ACCIDENTALA', label: 'Accidentală' },
];
const RAD_EXPOSURE_TYPES = ['X externă', 'gamma externă', 'internă', 'externă și internă'];
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const VERDICT_LABELS: Record<MedicalVerdict, { text: string; color: string }> = {
apt: { text: 'Apt', color: 'teal' },
apt_perioada_adaptare: { text: 'Apt (adaptare)', color: 'cyan' },
apt_conditionat: { text: 'Apt condiționat', color: 'orange' },
inapt_temporar: { text: 'Inapt temporar', color: 'yellow' },
inapt: { text: 'Inapt', color: 'red' },
};
const CHECKUP_TYPE_LABELS: Record<string, string> = {
la_angajare: 'La angajare',
periodic: 'Periodic',
la_reluarea_activitatii: 'La reluarea activității',
la_incetarea_expunerii: 'La încetarea expunerii',
suplimentar: 'Suplimentar',
};
interface Props {
employeeId: string;
}
export function EmployeeMedicalTab({ employeeId }: Props) {
const qc = useQueryClient();
const [editProfile, setEditProfile] = useState(false);
const [deleteDocTarget, setDeleteDocTarget] = useState<{ checkupId: string; doc: GeneratedDoc } | null>(null);
const [deleteAllTarget, setDeleteAllTarget] = useState<{ checkupId: string; count: number } | null>(null);
const [deleteCheckupTarget, setDeleteCheckupTarget] = useState<MedicalCheckup | null>(null);
const [profileForm, setProfileForm] = useState<{
ocupatieCorm: string;
workplaceRiskCardId: string;
dataUltimControlMedical: string;
expusRadiatiiIonizante: boolean;
dataIntrarii: string;
expunereAnterioaraPerioda: string;
expunereAnterioaraAni: number;
dozaCumulataExternaMsv: number;
dozaCumulataInternaMsv: number;
overexposures: RadiationOverexposure[];
}>({
ocupatieCorm: '',
workplaceRiskCardId: '',
dataUltimControlMedical: '',
expusRadiatiiIonizante: false,
dataIntrarii: '',
expunereAnterioaraPerioda: '',
expunereAnterioaraAni: 0,
dozaCumulataExternaMsv: 0,
dozaCumulataInternaMsv: 0,
overexposures: [],
});
const { data: profile, isLoading: profileLoading } = useQuery({
queryKey: ['medical-profile', employeeId],
queryFn: () => apiClient.get<EmployeeMedicalProfile | null>(`/medical/profiles/${employeeId}`).then((r) => r.data),
});
const { data: checkups, isLoading: checkupsLoading } = useQuery({
queryKey: ['medical-checkups', employeeId],
queryFn: () => apiClient.get<MedicalCheckup[]>(`/medical/checkups/employee/${employeeId}`).then((r) => r.data),
});
const { data: riskCards } = useQuery({
queryKey: ['risk-cards'],
queryFn: () => apiClient.get<WorkplaceRiskCard[]>('/medical/risk-cards').then((r) => r.data),
staleTime: 300_000,
});
const profileMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
apiClient.post(`/medical/profiles/${employeeId}`, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['medical-profile', employeeId] });
notifications.show({ color: 'medpark', title: 'Salvat', message: 'Profilul medical actualizat.' });
setEditProfile(false);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const deleteDocMutation = useMutation({
mutationFn: ({ checkupId, docName }: { checkupId: string; docName: string }) =>
apiClient.delete(`/medical/checkups/${checkupId}/documents`, { params: { name: docName } }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['medical-checkups', employeeId] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Documentul a fost șters.' });
setDeleteDocTarget(null);
},
onError: () => {
notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut șterge documentul.' });
},
});
const deleteCheckupMutation = useMutation({
mutationFn: (checkupId: string) => apiClient.delete(`/medical/checkups/${checkupId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['medical-checkups', employeeId] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Controlul medical a fost șters.' });
setDeleteCheckupTarget(null);
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut șterge.' }),
});
const deleteAllDocsMutation = useMutation({
mutationFn: (checkupId: string) =>
apiClient.delete(`/medical/checkups/${checkupId}/documents/all`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['medical-checkups', employeeId] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Toate documentele au fost șterse.' });
setDeleteAllTarget(null);
},
onError: () => {
notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-au putut șterge documentele.' });
},
});
async function downloadDoc(doc: GeneratedDoc) {
const key = doc.url.replace(/^s3:\/\/[^/]+\//, '');
try {
const res = await apiClient.get<{ url: string }>('/medical/documents/presign', { params: { key } });
window.open(res.data.url, '_blank');
} catch {
notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut genera linkul de descărcare.' });
}
}
function openProfileEdit() {
if (profile) {
setProfileForm({
ocupatieCorm: profile.ocupatieCorm ?? '',
workplaceRiskCardId: profile.workplaceRiskCardId ?? '',
dataUltimControlMedical: profile.dataUltimControlMedical ?? '',
expusRadiatiiIonizante: profile.expusRadiatiiIonizante,
dataIntrarii: profile.dataIntrarii ?? '',
expunereAnterioaraPerioda: profile.expunereAnterioaraPerioda ?? '',
expunereAnterioaraAni: profile.expunereAnterioaraAni ?? 0,
dozaCumulataExternaMsv: Number(profile.dozaCumulataExternaMsv ?? 0),
dozaCumulataInternaMsv: Number(profile.dozaCumulataInternaMsv ?? 0),
overexposures: (profile.overexposures ?? []).map((o) => ({
fel: o.fel,
tipExpunere: o.tipExpunere ?? '',
data: o.data ? dayjs(o.data).format('YYYY-MM-DD') : '',
dozaMsv: o.dozaMsv != null ? Number(o.dozaMsv) : undefined,
})),
});
}
setEditProfile(true);
}
// ── Supraexpuneri (Anexa 4B) helpers ──
const addOverexp = () =>
setProfileForm((f) => ({ ...f, overexposures: [...f.overexposures, { fel: 'EXCEPTIONALA', tipExpunere: '', data: '', dozaMsv: undefined }] }));
const removeOverexp = (i: number) =>
setProfileForm((f) => ({ ...f, overexposures: f.overexposures.filter((_, idx) => idx !== i) }));
const updateOverexp = (i: number, patch: Partial<RadiationOverexposure>) =>
setProfileForm((f) => ({ ...f, overexposures: f.overexposures.map((o, idx) => (idx === i ? { ...o, ...patch } : o)) }));
function handleProfileSave(e: React.FormEvent) {
e.preventDefault();
const payload: Record<string, unknown> = {
ocupatieCorm: profileForm.ocupatieCorm || undefined,
workplaceRiskCardId: profileForm.workplaceRiskCardId || undefined,
dataUltimControlMedical: profileForm.dataUltimControlMedical || undefined,
expusRadiatiiIonizante: profileForm.expusRadiatiiIonizante,
};
if (profileForm.expusRadiatiiIonizante) {
payload.dataIntrarii = profileForm.dataIntrarii || undefined;
payload.expunereAnterioaraPerioda = profileForm.expunereAnterioaraPerioda || undefined;
payload.expunereAnterioaraAni = profileForm.expunereAnterioaraAni || undefined;
payload.dozaCumulataExternaMsv = profileForm.dozaCumulataExternaMsv;
payload.dozaCumulataInternaMsv = profileForm.dozaCumulataInternaMsv;
payload.overexposures = profileForm.overexposures.map((o) => ({
fel: o.fel,
tipExpunere: o.tipExpunere || undefined,
data: o.data || undefined,
dozaMsv: o.dozaMsv != null && o.dozaMsv !== '' ? Number(o.dozaMsv) : undefined,
}));
}
profileMutation.mutate(payload);
}
const loading = profileLoading || checkupsLoading;
if (loading) {
return <Center h={200}><Loader color="medpark" size="sm" /></Center>;
}
return (
<Stack gap={20}>
{/* ── Medical Profile Section ── */}
<Paper p={20} shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, background: '#fff' }}>
<Group justify="space-between" mb={16}>
<Box>
<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1rem' }}>
Profil medical
</Text>
<Box style={{ width: 32, height: 2, background: teal, borderRadius: 2, marginTop: 4 }} />
</Box>
<Button size="xs" onClick={openProfileEdit} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
{profile ? 'Editează' : '+ Creează profil'}
</Button>
</Group>
{!profile ? (
<Text c="#adb5bd" ta="center" py={24} style={{ fontFamily: font, fontWeight: 300 }}>
Profilul medical nu a fost creat. Apăsați butonul de mai sus.
</Text>
) : (
<Group gap={32} align="flex-start" wrap="wrap">
<Box style={{ minWidth: 200 }}>
<InfoRow label="Ocupație CORM" value={profile.ocupatieCorm} />
<InfoRow label="Card de risc" value={profile.workplaceRiskCard?.name} highlight />
<InfoRow
label="Ultimul control"
value={profile.dataUltimControlMedical ? dayjs(profile.dataUltimControlMedical).format('DD.MM.YYYY') : null}
/>
</Box>
<Box style={{ minWidth: 200 }}>
<InfoRow label="Expus radiații" value={profile.expusRadiatiiIonizante ? 'Da' : 'Nu'} />
{profile.expusRadiatiiIonizante && (
<>
<InfoRow label="Data intrării" value={profile.dataIntrarii ? dayjs(profile.dataIntrarii).format('DD.MM.YYYY') : null} />
<InfoRow label="Expunere anterioară" value={profile.expunereAnterioaraPerioda} />
<InfoRow label="Ani expunere" value={profile.expunereAnterioaraAni?.toString()} />
</>
)}
</Box>
{profile.expusRadiatiiIonizante && (
<Paper p={14} style={{ background: '#f3e8ff', borderRadius: 8, minWidth: 200 }}>
<Text size="xs" fw={700} c="#8b5cf6" mb={6} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Doze radiații
</Text>
<InfoRow label="Doză externă (mSv)" value={Number(profile.dozaCumulataExternaMsv ?? 0).toFixed(4)} />
<InfoRow label="Doză internă (mSv)" value={Number(profile.dozaCumulataInternaMsv ?? 0).toFixed(4)} />
<Box style={{ borderTop: `1px solid #d8b4fe`, paddingTop: 6, marginTop: 6 }}>
<InfoRow label="DOZĂ TOTALĂ (mSv)" value={(profile.dozaTotalaMsv ?? 0).toFixed(4)} highlight />
</Box>
{(profile.overexposures?.length ?? 0) > 0 && (
<Box style={{ borderTop: `1px solid #d8b4fe`, paddingTop: 6, marginTop: 6 }}>
<Text size="xs" fw={700} c="#8b5cf6" mb={4} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.04em', fontSize: '0.65rem' }}>
Supraexpuneri ({profile.overexposures?.length})
</Text>
{profile.overexposures?.map((o, i) => (
<Text key={o.id ?? i} size="xs" c={charcoal} style={{ fontFamily: font, fontWeight: 300 }}>
{o.fel === 'ACCIDENTALA' ? 'Accidentală' : 'Excepțională'}
{o.tipExpunere ? `${o.tipExpunere}` : ''}
{o.data ? ` (${dayjs(o.data).format('DD.MM.YYYY')})` : ''}
{o.dozaMsv != null ? `${Number(o.dozaMsv).toFixed(4)} mSv` : ''}
</Text>
))}
</Box>
)}
</Paper>
)}
</Group>
)}
</Paper>
{/* ── Checkups History ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
<Box p="16px 20px" style={{ borderBottom: `1px solid ${border}` }}>
<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1rem' }}>
Istoricul controlului medical
</Text>
</Box>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Tip', 'Data planificată', 'Data efectuată', 'Verdict', 'Recomandări', 'Documente', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{!checkups?.length ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 32, fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Niciun control medical înregistrat
</td></tr>
) : checkups.map((c) => (
<tr key={c.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px' }}>
<Badge size="xs" variant="light" color="teal" style={{ fontFamily: font }}>
{CHECKUP_TYPE_LABELS[c.tip] ?? c.tip}
</Badge>
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{dayjs(c.dataPlanificata).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{c.dataEfectuata ? dayjs(c.dataEfectuata).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px' }}>
{c.verdict ? (
<Badge size="xs" color={VERDICT_LABELS[c.verdict]?.color ?? 'gray'} variant="filled" style={{ fontFamily: font }}>
{VERDICT_LABELS[c.verdict]?.text ?? c.verdict}
</Badge>
) : (
<Badge size="xs" color="gray" variant="light" style={{ fontFamily: font }}>În așteptare</Badge>
)}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.recomandari ?? '—'}
</td>
<td style={{ padding: '11px 14px' }}>
<Group gap={6} wrap="wrap">
{(c.documenteGenerate ?? []).map((d, i) => (
<Group key={i} gap={2} align="center" style={{ border: `1px solid ${border}`, borderRadius: 6, paddingLeft: 6, paddingRight: 2, paddingTop: 2, paddingBottom: 2 }}>
<Tooltip label="Descarcă" withArrow>
<Text
size="xs"
style={{ fontFamily: font, fontWeight: 500, color: teal, cursor: 'pointer', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
onClick={() => void downloadDoc(d)}
>
<IconDownload size={10} style={{ marginRight: 4, verticalAlign: 'middle' }} />
{d.name}
</Text>
</Tooltip>
<Tooltip label="Șterge" withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={() => setDeleteDocTarget({ checkupId: c.id, doc: d })}
>
<IconTrash size={10} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
))}
{!(c.documenteGenerate ?? []).length && <Text size="xs" c="#adb5bd" style={{ fontFamily: font }}></Text>}
{(c.documenteGenerate?.length ?? 0) > 1 && (
<Tooltip label="Șterge toate documentele" withArrow>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={11} stroke={1.5} />}
style={{ fontFamily: font, fontWeight: 500, fontSize: '0.7rem' }}
onClick={() => setDeleteAllTarget({ checkupId: c.id, count: c.documenteGenerate?.length ?? 0 })}
>
Toate
</Button>
</Tooltip>
)}
</Group>
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Tooltip label="Șterge controlul medical" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => setDeleteCheckupTarget(c)}
>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</Paper>
{/* ── Delete Document Modal ── */}
<Modal
opened={!!deleteDocTarget}
onClose={() => setDeleteDocTarget(null)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
Sigur doriți ștergeți documentul <strong>{deleteDocTarget?.doc.name}</strong>? Fișierul va fi eliminat și din server.
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setDeleteDocTarget(null)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
color="red"
loading={deleteDocMutation.isPending}
onClick={() => {
if (deleteDocTarget) {
deleteDocMutation.mutate({ checkupId: deleteDocTarget.checkupId, docName: deleteDocTarget.doc.name });
}
}}
style={{ fontFamily: font, fontWeight: 500 }}
>
Șterge
</Button>
</Group>
</Modal>
{/* ── Delete Checkup Modal ── */}
<Modal
opened={!!deleteCheckupTarget}
onClose={() => setDeleteCheckupTarget(null)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
Sigur doriți ștergeți controlul medical de tip{' '}
<strong>{CHECKUP_TYPE_LABELS[deleteCheckupTarget?.tip ?? ''] ?? deleteCheckupTarget?.tip}</strong>{' '}
din <strong>{deleteCheckupTarget ? dayjs(deleteCheckupTarget.dataPlanificata).format('DD.MM.YYYY') : ''}</strong>?
{(deleteCheckupTarget?.documenteGenerate?.length ?? 0) > 0 && (
<> Toate <strong>{deleteCheckupTarget?.documenteGenerate?.length}</strong> documente vor fi șterse și de pe server.</>
)}
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setDeleteCheckupTarget(null)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
color="red"
loading={deleteCheckupMutation.isPending}
onClick={() => deleteCheckupTarget && deleteCheckupMutation.mutate(deleteCheckupTarget.id)}
style={{ fontFamily: font, fontWeight: 500 }}
>
Șterge
</Button>
</Group>
</Modal>
{/* ── Delete All Documents Modal ── */}
<Modal
opened={!!deleteAllTarget}
onClose={() => setDeleteAllTarget(null)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
Sigur doriți ștergeți <strong>toate cele {deleteAllTarget?.count} documente</strong> din acest control medical? Fișierele vor fi eliminate și de pe server. Această acțiune este ireversibilă.
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setDeleteAllTarget(null)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
color="red"
loading={deleteAllDocsMutation.isPending}
onClick={() => deleteAllTarget && deleteAllDocsMutation.mutate(deleteAllTarget.checkupId)}
style={{ fontFamily: font, fontWeight: 500 }}
>
Șterge tot
</Button>
</Group>
</Modal>
{/* ── Profile Edit Modal ── */}
<Modal
opened={editProfile}
onClose={() => setEditProfile(false)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>Profil medical</Text>}
size="lg"
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={profileMutation.isPending} />
<form onSubmit={handleProfileSave}>
<Stack gap={14} mt={8}>
<TextInput
label="Ocupație CORM"
placeholder="Ex: 2212 — Medic specialist"
value={profileForm.ocupatieCorm}
onChange={(e) => setProfileForm((f) => ({ ...f, ocupatieCorm: e.currentTarget.value }))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<Select
label="Card de risc *"
data={(riskCards ?? []).map((c) => ({ value: c.id, label: c.name }))}
value={profileForm.workplaceRiskCardId || null}
onChange={(v) => setProfileForm((f) => ({ ...f, workplaceRiskCardId: v ?? '' }))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
clearable
/>
<DateInput
label="Data ultimului control medical"
value={profileForm.dataUltimControlMedical ? new Date(profileForm.dataUltimControlMedical) : null}
onChange={(d) => setProfileForm((f) => ({ ...f, dataUltimControlMedical: d ? dayjs(d).format('YYYY-MM-DD') : '' }))}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<Switch
label="Expus radiații ionizante"
checked={profileForm.expusRadiatiiIonizante}
onChange={(e) => setProfileForm((f) => ({ ...f, expusRadiatiiIonizante: e.currentTarget.checked }))}
color="teal"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
{profileForm.expusRadiatiiIonizante && (
<Paper p={14} style={{ background: '#f3e8ff', borderRadius: 8, border: '1px solid #d8b4fe' }}>
<Group gap={6} mb={8}>
<IconRadioactive size={14} color="#8b5cf6" stroke={1.5} />
<Text size="xs" fw={700} c="#8b5cf6" style={{ fontFamily: font, textTransform: 'uppercase' }}>
Date radiații ionizante
</Text>
</Group>
<Stack gap={10}>
<DateInput
label="Data intrării în mediul cu radiații"
value={profileForm.dataIntrarii ? new Date(profileForm.dataIntrarii) : null}
onChange={(d) => setProfileForm((f) => ({ ...f, dataIntrarii: d ? dayjs(d).format('YYYY-MM-DD') : '' }))}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
required
/>
<Group grow>
<TextInput
label="Expunere anterioară (perioadă)"
value={profileForm.expunereAnterioaraPerioda}
onChange={(e) => setProfileForm((f) => ({ ...f, expunereAnterioaraPerioda: e.currentTarget.value }))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<NumberInput
label="Ani de expunere anterioară"
value={profileForm.expunereAnterioaraAni}
onChange={(v) => setProfileForm((f) => ({ ...f, expunereAnterioaraAni: Number(v ?? 0) }))}
min={0}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
</Group>
<Group grow>
<NumberInput
label="Doză cumulată externă (mSv)"
value={profileForm.dozaCumulataExternaMsv}
onChange={(v) => setProfileForm((f) => ({ ...f, dozaCumulataExternaMsv: Number(v ?? 0) }))}
decimalScale={4}
min={0}
step={0.0001}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<NumberInput
label="Doză cumulată internă (mSv)"
value={profileForm.dozaCumulataInternaMsv}
onChange={(v) => setProfileForm((f) => ({ ...f, dozaCumulataInternaMsv: Number(v ?? 0) }))}
decimalScale={4}
min={0}
step={0.0001}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
</Group>
<Text size="xs" c="#8b5cf6" style={{ fontFamily: font, fontWeight: 500 }}>
Doză totală: {(profileForm.dozaCumulataExternaMsv + profileForm.dozaCumulataInternaMsv).toFixed(4)} mSv
</Text>
{/* ── Supraexpuneri (Anexa 4B) ── */}
<Box style={{ borderTop: '1px solid #d8b4fe', paddingTop: 8 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" fw={700} c="#8b5cf6" style={{ fontFamily: font, textTransform: 'uppercase' }}>
Supraexpuneri
</Text>
<Button size="compact-xs" variant="light" color="grape" leftSection={<IconPlus size={12} />}
onClick={addOverexp} style={{ fontFamily: font }}>
Adaugă
</Button>
</Group>
{profileForm.overexposures.length === 0 && (
<Text size="xs" c="#adb5bd" style={{ fontFamily: font }}>Nicio supraexpunere înregistrată.</Text>
)}
<Stack gap={8}>
{profileForm.overexposures.map((o, i) => (
<Group key={i} gap={6} align="flex-end" wrap="nowrap">
<Select label="Fel" data={OVEREXPOSURE_KINDS} value={o.fel} allowDeselect={false}
onChange={(v) => v && updateOverexp(i, { fel: v as OverexposureKind })}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.72rem' } }} style={{ width: 130 }} />
<Select label="Tip expunere" data={RAD_EXPOSURE_TYPES} value={o.tipExpunere || null} clearable
onChange={(v) => updateOverexp(i, { tipExpunere: v ?? '' })}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.72rem' } }} style={{ flex: 1 }} />
<DateInput label="Data" valueFormat="DD.MM.YYYY" value={o.data ? new Date(o.data) : null}
onChange={(d) => updateOverexp(i, { data: d ? dayjs(d).format('YYYY-MM-DD') : '' })}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.72rem' } }} style={{ width: 130 }} />
<NumberInput label="Doză (mSv)" value={o.dozaMsv == null ? '' : Number(o.dozaMsv)} decimalScale={4} min={0} step={0.0001}
onChange={(v) => updateOverexp(i, { dozaMsv: v === '' || v == null ? undefined : Number(v) })}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.72rem' } }} style={{ width: 120 }} />
<Tooltip label="Elimină">
<ActionIcon variant="subtle" color="red" onClick={() => removeOverexp(i)} mb={4}>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
))}
</Stack>
</Box>
</Stack>
</Paper>
)}
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setEditProfile(false)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button type="submit" loading={profileMutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
Salvează
</Button>
</Group>
</form>
</Box>
</Modal>
</Stack>
);
}
// ── Helper Components ──
function InfoRow({ label, value, highlight }: { label: string; value?: string | null; highlight?: boolean }) {
return (
<Box mb={6}>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', fontSize: '0.65rem' }}>
{label}
</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: highlight ? 600 : 300, color: highlight ? teal : charcoal }}>
{value ?? '—'}
</Text>
</Box>
);
}
@@ -0,0 +1,76 @@
import { SimpleGrid, Text, Box } from '@mantine/core';
import dayjs from 'dayjs';
import type { Employee } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const MARITAL_LABELS: Record<string, string> = {
casatorit: 'Căsătorit',
necasatorit: 'Necăsătorit',
divortat: 'Divorțat',
vaduv: 'Văduv',
};
const SCIENTIFIC_LABELS: Record<string, string> = {
doctor: 'Doctor',
doctor_habilitat: 'Doctor habilitat',
};
function Field({ label, value }: { label: string; value?: string | null }) {
return (
<Box>
<Text
size="xs"
style={{
fontFamily: font,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: '#adb5bd',
marginBottom: 2,
}}
>
{label}
</Text>
<Text
size="sm"
style={{ fontFamily: font, fontWeight: value ? 400 : 300, color: value ? charcoal : '#ced4da' }}
>
{value ?? '—'}
</Text>
</Box>
);
}
interface Props {
employee: Employee;
}
export function PersonalTab({ employee }: Props) {
return (
<SimpleGrid cols={2} spacing={24}>
<Field label="IDNP" value={employee.idnp} />
<Field label="Data nașterii" value={employee.dataNasterii ? dayjs(employee.dataNasterii).format('DD.MM.YYYY') : null} />
<Field label="Sex" value={employee.sex === 'F' ? 'Feminin' : 'Masculin'} />
<Field label="Stare civilă" value={employee.stareCivila ? MARITAL_LABELS[employee.stareCivila] : null} />
<Field label="Domiciliu" value={employee.domiciliu} />
<Field label="Adresă reală" value={employee.adresaReala} />
<Field label="Telefon personal" value={employee.telefonPersonal} />
<Field label="Telefon serviciu" value={employee.telefonServiciu} />
<Field label="Email personal" value={employee.emailPersonal} />
<Field label="Email corporativ" value={employee.emailCorporativ} />
<Field label="Cod CPAS" value={employee.codCpas} />
<Field label="Grad dizabilitate" value={employee.gradDizabilitate?.name} />
<Field label="Titlu științific" value={employee.titluStiintific ? SCIENTIFIC_LABELS[employee.titluStiintific] : null} />
<Field label="Titlu universitar" value={employee.titluUniversitar} />
{employee.numeAnterior && <Field label="Nume anterior" value={employee.numeAnterior} />}
{employee.recomandareInterna && (
<Field
label="Recomandare internă"
value={`${employee.recomandareInterna.nume} ${employee.recomandareInterna.prenume}`}
/>
)}
</SimpleGrid>
);
}
@@ -0,0 +1,98 @@
import { Box, Button, Text, Group } from '@mantine/core';
import dayjs from 'dayjs';
import type { DisciplinarySanction } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const TIP_LABELS: Record<string, string> = {
avertisment: 'Avertisment',
mustrare: 'Mustrare',
mustrare_aspra: 'Mustrare aspră',
};
function isActive(s: DisciplinarySanction): boolean {
return !s.isStinsa && dayjs(s.dataExpirarii).isAfter(dayjs());
}
interface Props {
sanctions: DisciplinarySanction[];
onAdd: () => void;
onEdit: (s: DisciplinarySanction) => void;
}
export function SanctiuniTab({ sanctions, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ Adaugă sancțiune
</Button>
</Group>
{sanctions.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Nicio sancțiune disciplinară
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Tip', 'Data aplicării', 'Data expirării', 'Statut', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{sanctions.map((s) => {
const active = isActive(s);
return (
<tr
key={s.id}
style={{
borderBottom: `1px solid ${border}`,
background: active ? '#fff5f5' : 'transparent',
}}
>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: active ? 600 : 400, fontSize: '0.875rem', color: active ? '#b11116' : charcoal }}>
{TIP_LABELS[s.tip] ?? s.tip}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{dayjs(s.dataAplicarii).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{dayjs(s.dataExpirarii).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px' }}>
<Box style={{
display: 'inline-flex', padding: '2px 8px', borderRadius: 20,
background: active ? '#ffeaea' : '#f0f0f0',
color: active ? '#b11116' : '#adb5bd',
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{active ? 'Activă' : 'Stinsă'}
</Box>
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }} onClick={() => onEdit(s)}>
Editează
</Text>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,99 @@
import { Box, Button, Text, Group } from '@mantine/core';
import dayjs from 'dayjs';
import type { Education } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const TIP_LABELS: Record<string, string> = {
superioare: 'Superioare',
medii_de_specialitate: 'Medii de specialitate',
secundare_tehnice: 'Secundare tehnice',
medii: 'Medii',
};
const CONFIRMARE_LABELS: Record<string, string> = {
confirmata: 'Confirmată',
neconfirmata: 'Neconfirmată',
};
interface Props {
educations: Education[];
onAdd: () => void;
onEdit: (e: Education) => void;
}
export function StudiiTab({ educations, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ Adaugă studii
</Button>
</Group>
{educations.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Nicio înregistrare de studii
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Tip studii', 'Instituție', 'Specialitate', 'An absolvire', 'Diplomă nr.', 'Confirmare', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{educations.map((edu) => (
<tr key={edu.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{TIP_LABELS[edu.tipStudii] ?? edu.tipStudii}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{edu.institutia}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{edu.specialitatea}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{edu.dataAbsolvirii ? dayjs(edu.dataAbsolvirii).format('YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: "'Courier New', monospace", fontSize: '0.8rem', color: '#6c757d' }}>
{edu.nrSeriaDiploma ?? '—'}
</td>
<td style={{ padding: '11px 14px' }}>
{edu.confirmare ? (
<Box style={{
display: 'inline-flex', alignItems: 'center',
padding: '2px 8px', borderRadius: 20,
background: edu.confirmare === 'confirmata' ? '#e6f4f4' : '#fff3ee',
color: edu.confirmare === 'confirmata' ? teal : '#f15a31',
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
}}>
{CONFIRMARE_LABELS[edu.confirmare]}
</Box>
) : '—'}
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }} onClick={() => onEdit(edu)}>
Editează
</Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,88 @@
import { Box, Button, Text, Group } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import dayjs from 'dayjs';
import type { Training } from '../../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const TIP_LABELS: Record<string, string> = {
orientare: 'Orientare',
intern: 'Intern',
extern_RM: 'Extern (RM)',
extern_international: 'Extern internațional',
};
interface Props {
trainings: Training[];
onAdd: () => void;
onEdit: (t: Training) => void;
}
export function TrainingTab({ trainings, onAdd, onEdit }: Props) {
return (
<Box>
<Group justify="flex-end" mb={12}>
<Button size="xs" onClick={onAdd} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
+ Adaugă training
</Button>
</Group>
{trainings.length === 0 ? (
<Text c="#adb5bd" ta="center" py={32} style={{ fontFamily: font, fontWeight: 300 }}>
Niciun training înregistrat
</Text>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Denumire', 'Tip', 'Început', 'Sfârșit', 'Ore', 'Organizație', 'Certificat', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.06em', color: charcoal,
padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{trainings.map((t) => (
<tr key={t.id} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{t.denumire}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{TIP_LABELS[t.tip] ?? t.tip}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{dayjs(t.inceput).format('DD.MM.YYYY')}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{t.sfirsit ? dayjs(t.sfirsit).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{t.nrOre ?? '—'}
</td>
<td style={{ padding: '11px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{t.organizatia ?? '—'}
</td>
<td style={{ padding: '11px 14px', textAlign: 'center', fontSize: '1rem' }}>
{t.certificat ? <IconCheck size={16} color="#008286" stroke={2} /> : <span style={{ color: '#adb5bd' }}></span>}
</td>
<td style={{ padding: '11px 14px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer' }} onClick={() => onEdit(t)}>
Editează
</Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Box>
);
}
@@ -0,0 +1,199 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
Loader, Center, Alert,
} from '@mantine/core';
import { IconBuildingHospital, IconCalendar } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import { StatusBadge } from './components/StatusBadge';
import { CategoryBadge } from './components/CategoryBadge';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
interface EvalForm {
id: string;
completedAt: string | null;
categorieCalculata: string | null;
categorieAprobata: string | null;
employee: { id: string; idnp: string; nume: string; prenume: string; status: string };
}
interface Campaign {
id: string;
name: string;
month: string;
status: string;
department: { name: string };
forms: EvalForm[];
}
const STATUS_TRANSITIONS: Record<string, { label: string; value: string }[]> = {
draft: [{ value: 'scheduled', label: 'Planifică' }],
scheduled: [{ value: 'in_progress', label: 'Lansează' }, { value: 'draft', label: 'Înapoi la draft' }],
in_progress: [{ value: 'closed', label: 'Închide campania' }],
closed: [],
};
export function CampaignDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: campaign, isLoading, error } = useQuery({
queryKey: ['campaign', id],
queryFn: () => apiClient.get<Campaign>(`/evaluation/campaigns/${id}`).then((r) => r.data),
enabled: !!id,
});
const generateMutation = useMutation({
mutationFn: () => apiClient.post<{ generated: number }>(`/evaluation/campaigns/${id}/generate-forms`),
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: ['campaign', id] });
notifications.show({ color: 'medpark', title: 'Formulare generate', message: `${r.data.generated} formulare create.` });
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const statusMutation = useMutation({
mutationFn: (status: string) => apiClient.patch(`/evaluation/campaigns/${id}/status`, { status }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['campaign', id] });
void qc.invalidateQueries({ queryKey: ['campaigns'] });
notifications.show({ color: 'medpark', title: 'Actualizat', message: 'Statut schimbat.' });
},
});
if (isLoading) return <Center h={400}><Loader color="medpark" /></Center>;
if (error || !campaign) return <Alert color="red">Campania nu a fost găsită.</Alert>;
const transitions = STATUS_TRANSITIONS[campaign.status] ?? [];
const completedCount = campaign.forms.filter((f) => f.completedAt).length;
const approvedCount = campaign.forms.filter((f) => f.categorieAprobata).length;
return (
<Stack gap={24}>
{/* Header */}
<Box style={{ background: '#fff', border: `1px solid ${border}`, borderRadius: 8, padding: '24px 28px', borderLeft: `4px solid ${teal}` }}>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 500, cursor: 'pointer', marginBottom: 8 }} onClick={() => navigate('/evaluation')}>
Evaluare performanță
</Text>
<Group justify="space-between" align="flex-start">
<Box>
<Group gap={12} mb={6}>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.4rem' }}>
{campaign.name}
</Title>
<StatusBadge status={campaign.status} />
</Group>
<Group gap={20}>
<Group gap={5} align="center">
<IconBuildingHospital size={15} color="#6c757d" stroke={1.5} />
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: '#6c757d' }}>
{campaign.department.name}
</Text>
</Group>
<Group gap={5} align="center">
<IconCalendar size={15} color="#6c757d" stroke={1.5} />
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: '#6c757d' }}>
{dayjs(campaign.month).format('MMMM YYYY')}
</Text>
</Group>
<Text size="sm" style={{ fontFamily: font, fontWeight: 500, color: teal }}>
{campaign.forms.length} angajați {completedCount} completate {approvedCount} aprobate
</Text>
</Group>
</Box>
<Group gap={10}>
{campaign.status === 'draft' && (
<Button
loading={generateMutation.isPending}
onClick={() => generateMutation.mutate()}
variant="outline"
style={{ borderColor: teal, color: teal, fontFamily: font, fontWeight: 500 }}
>
Generează formulare
</Button>
)}
{transitions.map((t) => (
<Button
key={t.value}
loading={statusMutation.isPending}
onClick={() => statusMutation.mutate(t.value)}
style={{
background: t.value === 'closed' ? '#b11116' : teal,
fontFamily: font, fontWeight: 500,
}}
>
{t.label}
</Button>
))}
</Group>
</Group>
</Box>
{/* Forms table */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{campaign.forms.length === 0 ? (
<Center py={48}>
<Stack align="center" gap={8}>
<Text style={{ fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Niciun formular. Apăsați «Generează formulare» pentru a adăuga angajații eligibili.
</Text>
</Stack>
</Center>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Angajat', 'IDNP', 'Categorie calculată', 'Categorie aprobată', 'Completat', ''].map((h, i) => (
<th key={i} style={{ fontFamily: font, fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal, padding: '14px 16px', textAlign: 'left', borderBottom: `2px solid ${teal}` }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{campaign.forms.map((f) => (
<tr key={f.id}
onClick={() => navigate(`/evaluation/form/${f.id}`)}
style={{ borderBottom: `1px solid ${border}`, cursor: 'pointer' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = '#e6f4f4')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = 'transparent')}
>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{f.employee.nume} {f.employee.prenume}
</td>
<td style={{ padding: '13px 16px', fontFamily: "'Courier New', monospace", fontSize: '0.8rem', color: '#6c757d' }}>
{f.employee.idnp}
</td>
<td style={{ padding: '13px 16px' }}>
<CategoryBadge category={f.categorieCalculata} />
</td>
<td style={{ padding: '13px 16px' }}>
<CategoryBadge category={f.categorieAprobata} />
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: f.completedAt ? '#2d6a4f' : '#adb5bd' }}>
{f.completedAt ? dayjs(f.completedAt).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '13px 16px', textAlign: 'right' }}>
<Text size="xs" c={teal} style={{ fontFamily: font, fontWeight: 500 }}>Completează </Text>
</td>
</tr>
))}
</tbody>
</table>
)}
</Paper>
</Stack>
);
}
@@ -0,0 +1,342 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
Loader, Center, Alert, Checkbox, Select, Textarea, Divider,
} from '@mantine/core';
import { useForm, Controller } from 'react-hook-form';
import { notifications } from '@mantine/notifications';
import { useEffect } from 'react';
import { apiClient } from '../../api/client';
import { ScoreInput } from './components/ScoreInput';
import { CategoryBadge } from './components/CategoryBadge';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
type Score = 'slab' | 'mediu' | 'bine' | null;
interface JciResult { score: number; max_score: number; percent: number; completed_at: string; source: string }
interface EvalFormData {
id: string;
campaignId: string;
completedAt: string | null;
categorieCalculata: string | null;
categorieAprobata: string | null;
observatii: string | null;
// A
abilitatiClinice: Score; judecataClinica: Score; manopere: Score; gestionareaSarcinilor: Score;
// B
constiintaProfesionala: Score; atitudineaPacienti: Score; atitudineaColegi: Score; atitudineaPersonalNonMed: Score;
// C
utilizareSmartphone: Score; respectareaProgramului: Score; respectareaDressCode: Score;
// D
testJci: JciResult | null; completareaDocMed: boolean | null; perfectioneazaCunostinte: boolean | null;
// E
membruComitetCalitate: boolean | null; functieDeMonitor: boolean | null; inlocuiesteSuperiorul: boolean | null;
employee: { id: string; idnp: string; nume: string; prenume: string };
campaign: { name: string; status: string; department: { name: string } };
}
// Doar aceste câmpuri există pe UpdateFormDto (apps/api/.../dto/update-form.dto.ts).
// API-ul rulează ValidationPipe cu forbidNonWhitelisted, deci orice cheie în plus → 400.
const EDITABLE_KEYS = [
'abilitatiClinice', 'judecataClinica', 'manopere', 'gestionareaSarcinilor',
'constiintaProfesionala', 'atitudineaPacienti', 'atitudineaColegi', 'atitudineaPersonalNonMed',
'utilizareSmartphone', 'respectareaProgramului', 'respectareaDressCode',
'completareaDocMed', 'perfectioneazaCunostinte',
'membruComitetCalitate', 'functieDeMonitor', 'inlocuiesteSuperiorul',
'observatii',
] as const;
function pickEditable(d: Partial<EvalFormData>): Record<string, unknown> {
const allowed = new Set<string>(EDITABLE_KEYS);
return Object.fromEntries(Object.entries(d).filter(([k]) => allowed.has(k)));
}
function SectionTitle({ children }: { children: string }) {
return (
<Box mb={4} mt={4}>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: teal }}>{children}</Text>
<Box style={{ height: 2, background: teal, width: 28, borderRadius: 1, marginTop: 4 }} />
</Box>
);
}
export function EvaluationFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: form, isLoading, error } = useQuery({
queryKey: ['eval-form', id],
queryFn: () => apiClient.get<EvalFormData>(`/evaluation/forms/${id}`).then((r) => r.data),
enabled: !!id,
});
const { control, handleSubmit, reset, watch, formState: { isSubmitting, isDirty } } =
useForm<Partial<EvalFormData>>({});
useEffect(() => { if (form) reset(form); }, [form, reset]);
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.patch(`/evaluation/forms/${id}`, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['eval-form', id] });
void qc.invalidateQueries({ queryKey: ['campaign', form?.campaignId] });
notifications.show({ color: 'medpark', title: 'Salvat', message: 'Formularul a fost actualizat.' });
},
onError: () => notifications.show({ color: 'red', title: 'Eroare', message: 'Nu s-a putut salva.' }),
});
const approveMutation = useMutation({
mutationFn: ({ categorieAprobata, observatii }: { categorieAprobata: string; observatii?: string }) =>
apiClient.patch(`/evaluation/forms/${id}/approve`, { categorieAprobata, observatii }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['eval-form', id] });
notifications.show({ color: 'medpark', title: 'Aprobat', message: 'Categoria a fost aprobată.' });
},
});
const categorieAprobata = watch('categorieAprobata');
const observatii = watch('observatii');
if (isLoading) return <Center h={400}><Loader color="medpark" /></Center>;
if (error || !form) return <Alert color="red">Formularul nu a fost găsit.</Alert>;
const isClosed = form.campaign.status === 'closed';
const role = localStorage.getItem('kc_role') ?? '';
const canApprove = role === 'nursing_director';
// updateForm (completarea scorurilor) — doar aceste roluri, conform evaluation.controller.ts
const canEdit = ['hr_admin', 'quality_auditor', 'manager'].includes(role);
const readOnly = isClosed || !canEdit;
return (
<Stack gap={20}>
{/* Back + header */}
<Box style={{ background: '#fff', border: `1px solid ${border}`, borderRadius: 8, padding: '20px 28px', borderLeft: `4px solid ${teal}` }}>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, cursor: 'pointer', marginBottom: 8 }}
onClick={() => navigate(`/evaluation/${form.campaignId}`)}>
{form.campaign.name}
</Text>
<Group justify="space-between">
<Box>
<Title order={3} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.25rem' }}>
{form.employee.nume} {form.employee.prenume}
</Title>
<Group gap={16} mt={4}>
<Text size="sm" style={{ fontFamily: "'Courier New', monospace", color: '#6c757d' }}>{form.employee.idnp}</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: '#6c757d' }}>{form.campaign.department.name}</Text>
</Group>
</Box>
<Group gap={12}>
<Box>
<Text size="xs" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#adb5bd', marginBottom: 2 }}>Calculat</Text>
<CategoryBadge category={form.categorieCalculata} />
</Box>
<Box>
<Text size="xs" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#adb5bd', marginBottom: 2 }}>Aprobat</Text>
<CategoryBadge category={form.categorieAprobata} />
</Box>
</Group>
</Group>
</Box>
{!isClosed && !canEdit && (
<Alert color="yellow" variant="light"
styles={{ message: { fontFamily: font, fontSize: '0.8rem' } }}>
Rolul {role} nu poate completa scorurile acestui formular (mod doar-citire).
{canApprove && ' Puteți doar aproba categoria finală în secțiunea F.'}
</Alert>
)}
<form onSubmit={handleSubmit((d) => updateMutation.mutate(pickEditable(d)))}>
<Stack gap={16}>
{/* ── A. Competente clinice ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>A. Competențe clinice</SectionTitle>
<Stack gap={0} mt={12}>
{([
['abilitatiClinice', 'Abilități clinice nursing'],
['judecataClinica', 'Judecată clinică / analiza situației'],
['manopere', 'Manopere (recoltare, pansamente, etc.)'],
['gestionareaSarcinilor', 'Gestionarea sarcinilor (prioritizare)'],
] as [keyof EvalFormData, string][]).map(([field, label]) => (
<Controller key={field} name={field} control={control} render={({ field: f }) => (
<ScoreInput label={label} value={f.value as Score} onChange={f.onChange} readOnly={readOnly} />
)} />
))}
</Stack>
</Paper>
{/* ── B. Comunicare ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>B. Comunicare și empatie</SectionTitle>
<Stack gap={0} mt={12}>
{([
['constiintaProfesionala', 'Conștiința profesională'],
['atitudineaPacienti', 'Atitudine față de pacienți'],
['atitudineaColegi', 'Atitudine față de colegi'],
['atitudineaPersonalNonMed', 'Atitudine față de personalul non-medical'],
] as [keyof EvalFormData, string][]).map(([field, label]) => (
<Controller key={field} name={field} control={control} render={({ field: f }) => (
<ScoreInput label={label} value={f.value as Score} onChange={f.onChange} readOnly={readOnly} />
)} />
))}
</Stack>
</Paper>
{/* ── C. Disciplina ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>C. Disciplină</SectionTitle>
<Stack gap={0} mt={12}>
{([
['utilizareSmartphone', 'Utilizarea smartphone în scopuri distractive'],
['respectareaProgramului', 'Respectarea programului de lucru (punctualitate)'],
['respectareaDressCode', 'Respectarea Dress Code (manichiură, bijuterii)'],
] as [keyof EvalFormData, string][]).map(([field, label]) => (
<Controller key={field} name={field} control={control} render={({ field: f }) => (
<ScoreInput label={label} value={f.value as Score} onChange={f.onChange} readOnly={readOnly} />
)} />
))}
</Stack>
</Paper>
{/* ── D. Documentatie ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>D. Documentație și complianță</SectionTitle>
<Stack gap={12} mt={12}>
{/* JCI test result */}
<Box style={{ padding: '12px 16px', borderRadius: 6, background: '#f8f9fa', border: `1px solid ${border}` }}>
<Text style={{ fontFamily: font, fontWeight: 500, fontSize: '0.8rem', color: charcoal, marginBottom: 4 }}>
Test JCI (Academy Ocean)
</Text>
{form.testJci ? (
<Group gap={16}>
<Text size="sm" style={{ fontFamily: font, fontWeight: 600, color: teal }}>
{form.testJci.score}/{form.testJci.max_score} ({form.testJci.percent}%)
</Text>
<Text size="xs" style={{ fontFamily: font, fontWeight: 300, color: '#6c757d' }}>
{new Date(form.testJci.completed_at).toLocaleDateString('ro-MD')}
</Text>
</Group>
) : (
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Rezultat neprimit din Academy Ocean
</Text>
)}
</Box>
<Controller name="completareaDocMed" control={control} render={({ field: f }) => (
<Checkbox label="Completarea documentației medicale (audit calitate-nurse)" checked={!!f.value}
onChange={(e) => f.onChange(e.currentTarget.checked)} disabled={readOnly}
color="medpark" styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.875rem' } }} />
)} />
<Controller name="perfectioneazaCunostinte" control={control} render={({ field: f }) => (
<Checkbox label="Își perfecționează cunoștințele cu regularitate (rezultate Academy Ocean)" checked={!!f.value}
onChange={(e) => f.onChange(e.currentTarget.checked)} disabled={readOnly}
color="medpark" styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.875rem' } }} />
)} />
</Stack>
</Paper>
{/* ── E. Candidat EXPERT ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>E. Candidat EXPERT</SectionTitle>
<Stack gap={10} mt={12}>
{([
['membruComitetCalitate', 'Membru comitet calitate (≥ 4 documente/an)'],
['functieDeMonitor', 'Funcția de mentor ≥ 6 luni'],
['inlocuiesteSuperiorul', 'Înlocuiește superiorul ierarhic'],
] as [keyof EvalFormData, string][]).map(([field, label]) => (
<Controller key={field} name={field} control={control} render={({ field: f }) => (
<Checkbox label={label} checked={!!f.value}
onChange={(e) => f.onChange(e.currentTarget.checked)} disabled={readOnly}
color="medpark" styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.875rem' } }} />
)} />
))}
</Stack>
</Paper>
{/* ── F. Verdict + Aprobare ── */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, padding: '20px 24px', background: '#fff' }}>
<SectionTitle>F. Verdict final</SectionTitle>
<Stack gap={12} mt={12}>
<Group align="center" gap={20}>
<Box>
<Text size="xs" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#adb5bd', marginBottom: 6 }}>
Categorie calculată automat
</Text>
<CategoryBadge category={form.categorieCalculata} />
</Box>
</Group>
{!isClosed && (
<>
<Divider />
{/* Observații — editabile de toți evaluatorii */}
<Controller name="observatii" control={control} render={({ field: f }) => (
<Textarea label="Observații" minRows={2} value={f.value as string ?? ''}
onChange={(e) => f.onChange(e.currentTarget.value)} readOnly={isClosed || (!canEdit && !canApprove)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
{/* Aprobarea categoriei finale — doar director nursing */}
{canApprove ? (
<>
<Text style={{ fontFamily: font, fontWeight: 600, fontSize: '0.8rem', color: charcoal }}>
Aprobare director nursing
</Text>
<Controller name="categorieAprobata" control={control} render={({ field: f }) => (
<Select label="Categorie aprobată" clearable
data={[
{ value: 'fara', label: 'Fără categorie' },
{ value: 'cat_II', label: 'Categoria II' },
{ value: 'cat_I', label: 'Categoria I' },
{ value: 'superioara', label: 'Superioară' },
]}
value={f.value as string ?? null}
onChange={(v) => f.onChange(v ?? undefined)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</>
) : (
<Alert color="yellow" variant="light"
styles={{ message: { fontFamily: font, fontSize: '0.8rem' } }}>
Doar directorul de nursing poate aproba categoria finală.
Rolul {role} nu are această permisiune.
</Alert>
)}
</>
)}
</Stack>
</Paper>
{/* Action buttons */}
{!isClosed && (
<Group justify="flex-end" gap={12}>
{canEdit && isDirty && (
<Button type="submit" loading={isSubmitting} variant="outline"
style={{ borderColor: teal, color: teal, fontFamily: font, fontWeight: 500 }}>
Salvează modificările
</Button>
)}
{canApprove && categorieAprobata && (
<Button
loading={approveMutation.isPending}
onClick={() => approveMutation.mutate({ categorieAprobata: categorieAprobata as string, observatii: observatii as string })}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}
>
Aprobă categoria
</Button>
)}
</Group>
)}
</Stack>
</form>
</Stack>
);
}
@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
Select, Modal, TextInput, LoadingOverlay, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { notifications } from '@mantine/notifications';
import { IconTrash } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import type { Department } from '../../api/types';
import { StatusBadge } from './components/StatusBadge';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
interface Campaign {
id: string;
name: string;
month: string;
status: string;
department: { name: string };
_count: { forms: number };
}
const schema = z.object({
name: z.string().min(2, 'Câmp obligatoriu'),
departmentId: z.string().uuid('Selectați departamentul'),
month: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
type FormValues = z.infer<typeof schema>;
export function EvaluationPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Campaign | null>(null);
const { data: campaigns, isLoading } = useQuery({
queryKey: ['campaigns'],
queryFn: () => apiClient.get<Campaign[]>('/evaluation/campaigns').then((r) => r.data),
});
const { data: depts } = useQuery({
queryKey: ['ref', 'departments-flat'],
queryFn: () => apiClient.get<Department[]>('/reference/departments/flat').then((r) => r.data),
staleTime: 300_000,
});
const { handleSubmit, register, control, reset, formState: { isSubmitting, errors } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
const createMutation = useMutation({
mutationFn: (data: FormValues) => apiClient.post('/evaluation/campaigns', data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['campaigns'] });
notifications.show({ color: 'medpark', title: 'Creat', message: 'Campanie creată.' });
setModalOpen(false);
reset();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/evaluation/campaigns/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['campaigns'] });
notifications.show({ color: 'medpark', title: 'Ștearsă', message: 'Campania a fost ștearsă.' });
setDeleteTarget(null);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare la ștergere.';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
setDeleteTarget(null);
},
});
return (
<Stack gap={24}>
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.5rem', marginBottom: 4 }}>
Evaluare performanță
</Title>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2 }} />
</Box>
<Button onClick={() => setModalOpen(true)} style={{ background: teal, fontFamily: font, fontWeight: 500, height: 40, paddingLeft: 20, paddingRight: 20 }}>
+ Campanie nouă
</Button>
</Group>
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Denumire', 'Departament', 'Luna', 'Formulare', 'Statut', ''].map((h, i) => (
<th key={i} style={{ fontFamily: font, fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal, padding: '14px 16px', textAlign: 'left', borderBottom: `2px solid ${teal}` }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{!campaigns?.length ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 48, fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Nicio campanie. Creați prima campanie de evaluare.
</td></tr>
) : campaigns.map((c) => (
<tr key={c.id}
onClick={() => navigate(`/evaluation/${c.id}`)}
style={{ borderBottom: `1px solid ${border}`, cursor: 'pointer' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = '#e6f4f4')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = 'transparent')}
>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>{c.name}</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>{c.department.name}</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>{dayjs(c.month).format('MMMM YYYY')}</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: teal }}>{c._count.forms}</td>
<td style={{ padding: '13px 16px' }}><StatusBadge status={c.status} /></td>
<td style={{ padding: '13px 16px', textAlign: 'right' }} onClick={(e) => e.stopPropagation()}>
<Tooltip label="Șterge campanie">
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => setDeleteTarget(c)}
disabled={c.status === 'in_progress'}
>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
)}
</Paper>
{/* Create Modal */}
<Modal opened={modalOpen} onClose={() => { setModalOpen(false); reset(); }}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>Campanie nouă</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}><LoadingOverlay visible={isSubmitting} />
<form onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
<Stack gap={12} mt={4}>
<TextInput label="Denumire campanie *" error={errors.name?.message} styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} {...register('name')} />
<Controller name="departmentId" control={control} render={({ field }) => (
<Select label="Departament *" error={errors.departmentId?.message}
data={(depts ?? []).map((d) => ({ value: d.id, label: d.name }))}
value={field.value ?? null} onChange={(v) => field.onChange(v ?? '')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="month" control={control} render={({ field }) => (
<DateInput label="Luna evaluării *" valueFormat="MMMM YYYY"
value={field.value ? new Date(field.value) : null}
onChange={(d) => field.onChange(d ? dayjs(d).startOf('month').format('YYYY-MM-DD') : '')}
error={errors.month?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => { setModalOpen(false); reset(); }} style={{ fontFamily: font, color: charcoal }}>Anulează</Button>
<Button type="submit" loading={isSubmitting} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>Creează</Button>
</Group>
</form>
</Box>
</Modal>
{/* Delete Confirm Modal */}
<Modal
opened={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: '#b11116' }}>Confirmare ștergere</Text>}
styles={{ header: { borderBottom: '2px solid #b11116' } }}
size="sm"
>
<Text size="sm" mb={20} style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
Sigur doriți ștergeți campania <strong>{deleteTarget?.name}</strong>? Toate formularele asociate vor fi șterse.
</Text>
<Group justify="flex-end" pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setDeleteTarget(null)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
style={{ fontFamily: font, fontWeight: 500 }}
>
Șterge
</Button>
</Group>
</Modal>
</Stack>
);
}
@@ -0,0 +1,27 @@
import { Box } from '@mantine/core';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const CONFIG = {
fara: { label: 'Fără categorie', bg: '#f0f0f0', color: '#6c757d' },
cat_II: { label: 'Categoria II', bg: '#e6f4f4', color: teal },
cat_I: { label: 'Categoria I', bg: '#d4edda', color: '#2d6a4f' },
superioara: { label: 'Superioară', bg: '#fff3e0', color: '#e65100' },
};
export function CategoryBadge({ category }: { category: string | null | undefined }) {
if (!category) return <Box style={{ color: '#ced4da', fontFamily: font, fontSize: '0.8rem' }}></Box>;
const cfg = CONFIG[category as keyof typeof CONFIG] ?? CONFIG.fara;
return (
<Box style={{
display: 'inline-flex', alignItems: 'center',
padding: '3px 10px', borderRadius: 20,
background: cfg.bg, color: cfg.color,
fontFamily: font, fontWeight: 600,
fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{cfg.label}
</Box>
);
}
@@ -0,0 +1,48 @@
import { Box, Text, SegmentedControl } from '@mantine/core';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
type Score = 'slab' | 'mediu' | 'bine' | null | undefined;
interface Props {
label: string;
value: Score;
onChange: (v: Score) => void;
readOnly?: boolean;
}
export function ScoreInput({ label, value, onChange, readOnly = false }: Props) {
return (
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text style={{ fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: '#58595b', flex: 1, paddingRight: 16 }}>
{label}
</Text>
{readOnly ? (
<Box style={{
padding: '3px 12px', borderRadius: 20,
background: value === 'bine' ? '#e6f4f4' : value === 'mediu' ? '#fff8e6' : value === 'slab' ? '#ffeaea' : '#f0f0f0',
color: value === 'bine' ? teal : value === 'mediu' ? '#f15a31' : value === 'slab' ? '#b11116' : '#adb5bd',
fontFamily: font, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase',
}}>
{value ?? '—'}
</Box>
) : (
<SegmentedControl
size="xs"
value={value ?? ''}
onChange={(v) => onChange((v as Score) || null)}
data={[
{ value: 'slab', label: 'Slab' },
{ value: 'mediu', label: 'Mediu' },
{ value: 'bine', label: 'Bine' },
]}
color="medpark"
styles={{
label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' },
}}
/>
)}
</Box>
);
}
@@ -0,0 +1,25 @@
import { Box } from '@mantine/core';
const font = "'Montserrat', Arial, sans-serif";
const CONFIG = {
draft: { label: 'Draft', bg: '#f0f0f0', color: '#6c757d' },
scheduled: { label: 'Planificat', bg: '#e6f4f4', color: '#008286' },
in_progress: { label: 'În desfășurare', bg: '#fff8e6', color: '#f15a31' },
closed: { label: 'Încheiat', bg: '#f0fff4', color: '#2d6a4f' },
};
export function StatusBadge({ status }: { status: string }) {
const cfg = CONFIG[status as keyof typeof CONFIG] ?? CONFIG.draft;
return (
<Box style={{
display: 'inline-flex', alignItems: 'center',
padding: '3px 10px', borderRadius: 20,
background: cfg.bg, color: cfg.color,
fontFamily: font, fontWeight: 600,
fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{cfg.label}
</Box>
);
}
@@ -0,0 +1,263 @@
import { useEffect } from 'react';
import {
Drawer, Button, Group, Stack, Text, LoadingOverlay, Box,
Select, TextInput, NumberInput, Switch, Divider,
} from '@mantine/core';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
import { apiClient } from '../../api/client';
import type { InventoryItem, InventoryItemType } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const TYPE_OPTIONS: { value: InventoryItemType; label: string }[] = [
{ value: 'uniforma', label: 'Uniformă' },
{ value: 'halat', label: 'Halat' },
{ value: 'ciupici', label: 'Ciupici' },
{ value: 'vesta', label: 'Vestă' },
{ value: 'aparat_telefon', label: 'Aparat telefon' },
{ value: 'alte', label: 'Altele' },
];
const schema = z.object({
sku: z.string().min(1, 'Câmp obligatoriu'),
name: z.string().min(1, 'Câmp obligatoriu'),
type: z.enum(['uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte']),
size: z.string().optional(),
color: z.string().optional(),
pricePerUnit: z.string().optional(),
stockQty: z.number().int().min(0, 'Stocul nu poate fi negativ'),
active: z.boolean(),
});
type FormValues = z.infer<typeof schema>;
interface Props {
inventoryItem: InventoryItem | null;
opened: boolean;
onClose: () => void;
}
export function InventoryDrawer({ inventoryItem, opened, onClose }: Props) {
const isEdit = !!inventoryItem;
const qc = useQueryClient();
const { handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
sku: '',
name: '',
type: 'uniforma',
size: '',
color: '',
pricePerUnit: '',
stockQty: 0,
active: true,
},
});
useEffect(() => {
if (inventoryItem) {
reset({
sku: inventoryItem.sku,
name: inventoryItem.name,
type: inventoryItem.type,
size: inventoryItem.size ?? '',
color: inventoryItem.color ?? '',
pricePerUnit: inventoryItem.pricePerUnit ?? '',
stockQty: inventoryItem.stockQty,
active: inventoryItem.active,
});
} else {
reset({
sku: '',
name: '',
type: 'uniforma',
size: '',
color: '',
pricePerUnit: '',
stockQty: 0,
active: true,
});
}
}, [inventoryItem, opened, reset]);
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const price = data.pricePerUnit ? Number(data.pricePerUnit) : undefined;
const payload = {
sku: data.sku,
name: data.name,
type: data.type,
size: data.size || undefined,
color: data.color || undefined,
pricePerUnit: Number.isFinite(price) ? price : undefined,
stockQty: data.stockQty,
active: data.active,
};
return isEdit
? apiClient.patch(`/inventory/${inventoryItem.id}`, payload)
: apiClient.post('/inventory', payload);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({
color: 'medpark',
title: 'Salvat',
message: isEdit ? 'Articol actualizat.' : 'Articol creat.',
});
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const deleteMutation = useMutation({
mutationFn: () => apiClient.delete(`/inventory/${inventoryItem!.id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Articol șters.' });
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
function confirmDelete() {
if (!inventoryItem) return;
modals.openConfirmModal({
title: <Text fw={600} style={{ fontFamily: font }}>Șterge articol</Text>,
children: (
<Text size="sm" style={{ fontFamily: font }}>
Ești sigur vrei ștergi articolul <b>{inventoryItem.sku}</b> {inventoryItem.name}?
Această acțiune nu poate fi anulată.
</Text>
),
labels: { confirm: 'Șterge', cancel: 'Anulează' },
confirmProps: { color: 'red' },
onConfirm: () => deleteMutation.mutate(),
});
}
const section = (label: string) => (
<Divider
label={<Text size="xs" fw={700} c={teal} style={{ fontFamily: font, letterSpacing: '0.06em', textTransform: 'uppercase' }}>{label}</Text>}
labelPosition="left" my={12}
/>
);
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>{isEdit ? 'Editare articol' : 'Articol nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={isSubmitting || mutation.isPending || deleteMutation.isPending} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={10} pt={8}>
{section('Identificare')}
<Group grow>
<Controller name="sku" control={control} render={({ field }) => (
<TextInput {...field} label="SKU *" placeholder="UN-001"
error={errors.sku?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="type" control={control} render={({ field }) => (
<Select label="Tip *" data={TYPE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? 'uniforma')}
error={errors.type?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Controller name="name" control={control} render={({ field }) => (
<TextInput {...field} label="Denumire *" placeholder="Uniformă medicală albă"
error={errors.name?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
{section('Caracteristici')}
<Group grow>
<Controller name="size" control={control} render={({ field }) => (
<TextInput {...field} label="Mărime" placeholder="M, L, 42..."
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="color" control={control} render={({ field }) => (
<TextInput {...field} label="Culoare" placeholder="Alb, albastru..."
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
{section('Stoc și preț')}
<Group grow>
<Controller name="pricePerUnit" control={control} render={({ field }) => (
<NumberInput
label="Preț unitar (MDL)"
placeholder="0"
decimalScale={2}
min={0}
value={field.value ? Number(field.value) : ''}
onChange={(v) => field.onChange(v === '' || v === null ? '' : String(v))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)} />
<Controller name="stockQty" control={control} render={({ field }) => (
<NumberInput
label={isEdit ? 'Stoc curent *' : 'Stoc inițial *'}
min={0}
value={field.value}
onChange={(v) => field.onChange(typeof v === 'number' ? v : 0)}
error={errors.stockQty?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)} />
</Group>
<Controller name="active" control={control} render={({ field }) => (
<Switch
label="Activ"
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="medpark"
styles={{ label: { fontFamily: font, fontSize: '0.875rem' } }}
mt={4}
/>
)} />
</Stack>
<Group justify="space-between" mt={20} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
{isEdit ? (
<Button color="red" variant="subtle" onClick={confirmDelete} style={{ fontFamily: font }}>
Șterge
</Button>
) : <Box />}
<Group gap={8}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button type="submit" loading={mutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
{isEdit ? 'Salvează' : 'Creează'}
</Button>
</Group>
</Group>
</form>
</Box>
</Drawer>
);
}
@@ -0,0 +1,241 @@
import { useState } from 'react';
import {
Title, Table, Badge, Group, Text, TextInput, Select, Switch, Button,
Box, Loader, Center, Pagination,
} from '@mantine/core';
import { IconSearch, IconPlus, IconAdjustments } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../api/client';
import type { InventoryItem, InventoryItemType, PaginatedInventory } from '../../api/types';
import { InventoryDrawer } from './InventoryDrawer';
import { StockAdjustModal } from './StockAdjustModal';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const amber = '#fbb034';
const red = '#b11116';
const TYPE_LABEL: Record<InventoryItemType, string> = {
uniforma: 'Uniformă',
halat: 'Halat',
ciupici: 'Ciupici',
vesta: 'Vestă',
aparat_telefon: 'Aparat telefon',
alte: 'Altele',
};
const TYPE_COLOR: Record<InventoryItemType, string> = {
uniforma: 'medpark',
halat: 'cyan',
ciupici: 'grape',
vesta: 'orange',
aparat_telefon: 'indigo',
alte: 'gray',
};
const TYPE_FILTER_OPTIONS = [
{ value: 'uniforma', label: 'Uniforme' },
{ value: 'halat', label: 'Halate' },
{ value: 'ciupici', label: 'Ciupici' },
{ value: 'vesta', label: 'Vesta' },
{ value: 'aparat_telefon', label: 'Aparate telefon' },
{ value: 'alte', label: 'Alte' },
];
export function InventoryPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [activeOnly, setActiveOnly] = useState(true);
const [page, setPage] = useState(1);
const limit = 50;
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [adjustOpen, setAdjustOpen] = useState(false);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (typeFilter) params.set('type', typeFilter);
if (activeOnly) params.set('active', 'true');
params.set('page', String(page));
params.set('limit', String(limit));
const { data, isLoading } = useQuery({
queryKey: ['inventory', search, typeFilter, activeOnly, page],
queryFn: () =>
apiClient.get<PaginatedInventory>(`/inventory?${params.toString()}`).then((r) => r.data),
staleTime: 30_000,
});
function openRow(item: InventoryItem) {
setSelectedItem(item);
setDrawerOpen(true);
}
function startAdd() {
setSelectedItem(null);
setDrawerOpen(true);
}
function startAdjust() {
if (!selectedItem) return;
setAdjustOpen(true);
}
const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1;
return (
<Box>
<Group justify="space-between" mb={24}>
<Box>
<Title order={2} style={{ fontFamily: font, color: charcoal }}>
Inventar
</Title>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 4 }} />
</Box>
<Group gap={8}>
{selectedItem && (
<Button
leftSection={<IconAdjustments size={16} />}
variant="outline"
onClick={startAdjust}
style={{ borderColor: teal, color: teal, fontFamily: font, fontWeight: 500, height: 40 }}
>
Ajustează stoc
</Button>
)}
<Button
leftSection={<IconPlus size={16} />}
onClick={startAdd}
style={{ background: teal, fontFamily: font, fontWeight: 500, height: 40 }}
>
Adaugă articol
</Button>
</Group>
</Group>
<Group mb={16} gap={12} wrap="wrap">
<TextInput
placeholder="Caută după SKU sau denumire..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => { setSearch(e.currentTarget.value); setPage(1); }}
style={{ width: 280 }}
styles={{ input: { fontFamily: font } }}
/>
<Select
placeholder="Toate tipurile"
data={TYPE_FILTER_OPTIONS}
value={typeFilter}
onChange={(v) => { setTypeFilter(v); setPage(1); }}
clearable
style={{ width: 220 }}
styles={{ input: { fontFamily: font } }}
/>
<Switch
label="Doar active"
checked={activeOnly}
onChange={(e) => { setActiveOnly(e.currentTarget.checked); setPage(1); }}
color="medpark"
styles={{ label: { fontFamily: font, fontSize: '0.875rem' } }}
/>
</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>SKU</Table.Th>
<Table.Th>Denumire</Table.Th>
<Table.Th>Tip</Table.Th>
<Table.Th>Mărime</Table.Th>
<Table.Th>Culoare</Table.Th>
<Table.Th>Stoc</Table.Th>
<Table.Th>Preț (MDL)</Table.Th>
<Table.Th>Acțiuni</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data?.items ?? []).map((it) => {
const stockColor =
it.stockQty === 0 ? red :
it.stockQty < 5 ? amber : charcoal;
const isSelected = selectedItem?.id === it.id;
return (
<Table.Tr
key={it.id}
onClick={() => openRow(it)}
style={{
cursor: 'pointer',
borderBottom: '1px solid #e9ecef',
background: isSelected ? '#e6f4f4' : undefined,
color: stockColor,
}}
>
<Table.Td fw={600} c={teal}>{it.sku}</Table.Td>
<Table.Td style={{ color: stockColor }}>{it.name}</Table.Td>
<Table.Td>
<Badge color={TYPE_COLOR[it.type]} size="sm" style={{ fontFamily: font }}>
{TYPE_LABEL[it.type]}
</Badge>
</Table.Td>
<Table.Td style={{ color: stockColor }}>{it.size ?? '—'}</Table.Td>
<Table.Td style={{ color: stockColor }}>{it.color ?? '—'}</Table.Td>
<Table.Td fw={600} style={{ color: stockColor }}>{it.stockQty}</Table.Td>
<Table.Td style={{ color: stockColor }}>{it.pricePerUnit ?? '—'}</Table.Td>
<Table.Td>
{!it.active && (
<Badge color="gray" size="sm" style={{ fontFamily: font }}>Inactiv</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
{(data?.items ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Text ta="center" c="dimmed" py={24} style={{ fontFamily: font }}>
Niciun articol găsit.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
)}
{data && data.total > limit && (
<Group justify="center" mt={16}>
<Pagination
total={totalPages}
value={page}
onChange={setPage}
color="medpark"
styles={{ control: { fontFamily: font } }}
/>
</Group>
)}
<InventoryDrawer
inventoryItem={selectedItem}
opened={drawerOpen}
onClose={() => { setDrawerOpen(false); }}
/>
{selectedItem && (
<StockAdjustModal
item={selectedItem}
opened={adjustOpen}
onClose={() => setAdjustOpen(false)}
/>
)}
</Box>
);
}
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { Modal, Button, Group, Stack, Text, NumberInput, Textarea, Box, LoadingOverlay } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { apiClient } from '../../api/client';
import type { InventoryItem } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const red = '#b11116';
interface Props {
item: InventoryItem;
opened: boolean;
onClose: () => void;
}
export function StockAdjustModal({ item, opened, onClose }: Props) {
const qc = useQueryClient();
const [delta, setDelta] = useState<number | ''>(0);
const [reason, setReason] = useState('');
const [reasonError, setReasonError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setDelta(0);
setReason('');
setReasonError(null);
}
}, [opened]);
const newStock = typeof delta === 'number' ? item.stockQty + delta : item.stockQty;
const stockInvalid = newStock < 0;
const mutation = useMutation({
mutationFn: () =>
apiClient.post(`/inventory/${item.id}/adjust-stock`, {
delta: typeof delta === 'number' ? delta : 0,
reason,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({
color: 'medpark',
title: 'Stoc ajustat',
message: `Stocul pentru ${item.sku} a fost actualizat.`,
});
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
function submit() {
const trimmed = reason.trim();
if (!trimmed) {
setReasonError('Cauza este obligatorie.');
return;
}
if (typeof delta !== 'number' || delta === 0) {
notifications.show({ color: 'red', title: 'Eroare', message: 'Delta nu poate fi zero.' });
return;
}
if (stockInvalid) {
notifications.show({ color: 'red', title: 'Eroare', message: 'Stocul rezultat nu poate fi negativ.' });
return;
}
setReasonError(null);
mutation.mutate();
}
return (
<Modal
opened={opened}
onClose={onClose}
size="md"
title={<Text fw={700} style={{ fontFamily: font, color: charcoal }}>Ajustare stoc</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={mutation.isPending} />
<Stack gap={14} pt={8}>
<Box style={{ background: '#f8f9fa', borderRadius: 6, padding: 12 }}>
<Text size="xs" fw={600} c={teal} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Articol
</Text>
<Text size="sm" fw={500} style={{ fontFamily: font, color: charcoal }}>
{item.sku} {item.name}
{item.size ? ` (${item.size})` : ''}
</Text>
</Box>
<Group grow>
<Box>
<Text size="xs" fw={600} c={charcoal} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Stoc curent
</Text>
<Text size="lg" fw={700} style={{ fontFamily: font, color: charcoal }}>
{item.stockQty}
</Text>
</Box>
<Box>
<Text size="xs" fw={600} c={charcoal} style={{ fontFamily: font, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Stoc nou
</Text>
<Text size="lg" fw={700} style={{ fontFamily: font, color: stockInvalid ? red : teal }}>
{newStock}
</Text>
</Box>
</Group>
<NumberInput
label="Delta (poate fi negativ)"
description="Folosește valori pozitive pentru intrare în stoc, negative pentru ieșire."
value={delta}
onChange={(v) => setDelta(typeof v === 'number' ? v : '')}
allowNegative
styles={{
label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' },
description: { fontFamily: font, fontSize: '0.75rem' },
}}
/>
<Textarea
label="Cauza ajustării *"
placeholder="Cauza ajustării (obligatoriu)"
value={reason}
onChange={(e) => { setReason(e.currentTarget.value); if (reasonError) setReasonError(null); }}
error={reasonError}
minRows={3}
autosize
styles={{
label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' },
input: { fontFamily: font },
}}
/>
<Group justify="flex-end" mt={4}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
onClick={submit}
disabled={stockInvalid}
loading={mutation.isPending}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}
>
Aplică ajustarea
</Button>
</Group>
</Stack>
</Box>
</Modal>
);
}
@@ -0,0 +1,428 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
Modal, Select, Checkbox, Loader, Center, Badge,
LoadingOverlay, TextInput, SimpleGrid,
} from '@mantine/core';
import { IconRadioactive, IconCheck } from '@tabler/icons-react';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import type {
UpcomingExpiration, MedicalCheckupType, WorkplaceRiskCard,
BulkInitiateResult, BulkDocumentContext,
} from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const CHECKUP_TYPES: { value: MedicalCheckupType; label: string }[] = [
{ value: 'la_angajare', label: 'La angajare' },
{ value: 'periodic', label: 'Periodic' },
{ value: 'la_reluarea_activitatii', label: 'La reluarea activității' },
{ value: 'la_incetarea_expunerii', label: 'La încetarea expunerii' },
{ value: 'suplimentar', label: 'Suplimentar' },
];
function ExpirationBadge({ dateStr }: { dateStr: string | null }) {
if (!dateStr) return <Badge size="xs" color="red" variant="filled" style={{ fontFamily: font }}>Niciodată</Badge>;
const daysAgo = dayjs().diff(dayjs(dateStr), 'day');
const monthsSince = dayjs().diff(dayjs(dateStr), 'month');
if (monthsSince >= 12) {
return <Badge size="xs" color="red" variant="filled" style={{ fontFamily: font }}>Expirat ({daysAgo - 365}z)</Badge>;
}
if (monthsSince >= 11) {
return <Badge size="xs" color="orange" variant="filled" style={{ fontFamily: font }}>Expiră curând</Badge>;
}
return <Badge size="xs" color="teal" variant="light" style={{ fontFamily: font }}>Valabil</Badge>;
}
export function MedicalControlPage() {
const qc = useQueryClient();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
const [checkupType, setCheckupType] = useState<MedicalCheckupType>('periodic');
const [dataPlanificata, setDataPlanificata] = useState<Date | null>(new Date());
const [documentContext, setDocumentContext] = useState<BulkDocumentContext>({});
const [filterCard, setFilterCard] = useState<string | null>(null);
const [resultModal, setResultModal] = useState<BulkInitiateResult | null>(null);
const { data: expirations, isLoading } = useQuery({
queryKey: ['medical', 'upcoming-expirations'],
queryFn: () => apiClient.get<UpcomingExpiration[]>('/medical/upcoming-expirations').then((r) => r.data),
});
const { data: riskCards } = useQuery({
queryKey: ['risk-cards'],
queryFn: () => apiClient.get<WorkplaceRiskCard[]>('/medical/risk-cards').then((r) => r.data),
staleTime: 300_000,
});
const bulkMutation = useMutation({
mutationFn: (payload: { employeeIds: string[]; tip: MedicalCheckupType; dataPlanificata: string; documentContext?: BulkDocumentContext }) =>
apiClient.post<BulkInitiateResult>('/medical/bulk/initiate', payload).then((r) => r.data),
onSuccess: (result) => {
void qc.invalidateQueries({ queryKey: ['medical'] });
setResultModal(result);
setModalOpen(false);
setSelectedIds(new Set());
notifications.show({
color: 'medpark',
title: 'Documente generate',
message: `${result.employeesCount} angajați, ${result.groupsCount} grupuri de risc`,
});
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const filtered = (expirations ?? []).filter((e) =>
!filterCard || e.workplaceRiskCard?.id === filterCard,
);
const toggleAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map((e) => e.employee.id)));
}
};
const toggle = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
function handleBulkInitiate(e: React.FormEvent) {
e.preventDefault();
if (!dataPlanificata) return;
const cleanedContext = Object.fromEntries(
Object.entries(documentContext)
.map(([k, v]) => [k, v?.trim()])
.filter(([, v]) => Boolean(v)),
) as BulkDocumentContext;
bulkMutation.mutate({
employeeIds: Array.from(selectedIds),
tip: checkupType,
dataPlanificata: dayjs(dataPlanificata).format('YYYY-MM-DD'),
documentContext: Object.keys(cleanedContext).length ? cleanedContext : undefined,
});
}
function setDocContextField(key: keyof BulkDocumentContext, value: string) {
setDocumentContext((prev) => ({ ...prev, [key]: value }));
}
return (
<Stack gap={24}>
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.5rem', marginBottom: 4 }}>
Control medical
</Title>
<Text size="sm" c="#adb5bd" style={{ fontFamily: font, fontWeight: 300 }}>
Inițiere control medical periodic conform NU-10-MS-2026
</Text>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 6 }} />
</Box>
<Group gap={12}>
<Select
placeholder="Filtrare card de risc"
data={[
{ value: '', label: 'Toate' },
...(riskCards ?? []).map((c) => ({ value: c.id, label: c.name })),
]}
value={filterCard ?? ''}
onChange={(v) => setFilterCard(v || null)}
clearable
size="sm"
style={{ minWidth: 240 }}
styles={{ input: { fontFamily: font, fontSize: '0.8rem' } }}
/>
<Button
disabled={selectedIds.size === 0}
onClick={() => setModalOpen(true)}
style={{
background: selectedIds.size > 0 ? teal : undefined,
fontFamily: font,
fontWeight: 500, height: 40,
paddingLeft: 20, paddingRight: 20,
}}
>
Generează documente ({selectedIds.size})
</Button>
</Group>
</Group>
{/* Stats cards */}
<Group gap={16} wrap="wrap">
{[
{ label: 'Total angajați', value: expirations?.length ?? 0, color: charcoal },
{ label: 'Control expirat', value: (expirations ?? []).filter((e) => {
if (!e.dataUltimControlMedical) return true;
return dayjs().diff(dayjs(e.dataUltimControlMedical), 'month') >= 12;
}).length, color: '#b11116' },
{ label: 'Expiră în 30 zile', value: (expirations ?? []).filter((e) => {
if (!e.dataUltimControlMedical) return false;
const m = dayjs().diff(dayjs(e.dataUltimControlMedical), 'month');
return m >= 11 && m < 12;
}).length, color: '#f15a31' },
{ label: 'Expuși radiații', value: (expirations ?? []).filter((e) => e.expusRadiatiiIonizante).length, color: '#8b5cf6' },
].map((stat) => (
<Paper key={stat.label} p={16} shadow="none"
style={{ border: `1px solid ${border}`, borderRadius: 8, background: '#fff', flex: '1 1 180px', minWidth: 180 }}
>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{stat.label}
</Text>
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '1.75rem', color: stat.color, marginTop: 4 }}>
{stat.value}
</Text>
</Paper>
))}
</Group>
{/* Employees table */}
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
<th style={{ padding: '14px 16px', textAlign: 'left', borderBottom: `2px solid ${teal}`, width: 40 }}>
<Checkbox
checked={filtered.length > 0 && selectedIds.size === filtered.length}
indeterminate={selectedIds.size > 0 && selectedIds.size < filtered.length}
onChange={toggleAll}
color="teal"
size="xs"
/>
</th>
{['IDNP', 'Angajat', 'Departament', 'Card de risc', 'Ultimul control', 'Statut', 'Rad.'].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal,
padding: '14px 12px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{!filtered.length ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 48, fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Niciun angajat cu control medical necesar.
</td></tr>
) : filtered.map((exp) => (
<tr key={exp.employee.id}
style={{
borderBottom: `1px solid ${border}`,
background: selectedIds.has(exp.employee.id) ? '#e6f4f4' : 'transparent',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
if (!selectedIds.has(exp.employee.id))
(e.currentTarget as HTMLElement).style.background = '#f8f9fa';
}}
onMouseLeave={(e) => {
if (!selectedIds.has(exp.employee.id))
(e.currentTarget as HTMLElement).style.background = 'transparent';
}}
onClick={() => toggle(exp.employee.id)}
>
<td style={{ padding: '11px 16px' }}>
<Checkbox
checked={selectedIds.has(exp.employee.id)}
onChange={() => toggle(exp.employee.id)}
color="teal"
size="xs"
/>
</td>
<td style={{ padding: '11px 12px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{exp.employee.idnp}
</td>
<td style={{ padding: '11px 12px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{exp.employee.nume} {exp.employee.prenume}
</td>
<td style={{ padding: '11px 12px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{exp.employee.contracts?.[0]?.department?.name ?? '—'}
</td>
<td style={{ padding: '11px 12px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{exp.workplaceRiskCard?.name ?? '—'}
</td>
<td style={{ padding: '11px 12px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{exp.dataUltimControlMedical ? dayjs(exp.dataUltimControlMedical).format('DD.MM.YYYY') : '—'}
</td>
<td style={{ padding: '11px 12px' }}>
<ExpirationBadge dateStr={exp.dataUltimControlMedical} />
</td>
<td style={{ padding: '11px 12px', textAlign: 'center' }}>
{exp.expusRadiatiiIonizante && (
<IconRadioactive size={16} color="#8b5cf6" stroke={1.5} title="Expus radiații ionizante" />
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Paper>
{/* Bulk Initiate Modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>Inițiere control medical</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={bulkMutation.isPending} />
<form onSubmit={handleBulkInitiate}>
<Stack gap={14} mt={8}>
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
<strong>{selectedIds.size}</strong> angajați selectați. Sistemul va grupa după cardul de risc
și va genera automat documentele conform regulamentului.
</Text>
<Select
label="Tipul controlului medical"
data={CHECKUP_TYPES}
value={checkupType}
onChange={(v) => setCheckupType((v as MedicalCheckupType) ?? 'periodic')}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<DateInput
label="Data planificată"
value={dataPlanificata}
onChange={setDataPlanificata}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
required
/>
<Paper p={12} style={{ background: '#fafafa', borderRadius: 6, border: `1px solid ${border}` }}>
<Text size="xs" style={{ fontFamily: font, fontWeight: 600, color: charcoal, marginBottom: 8 }}>
Date documente
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={8}>
<TextInput label="Telefon instituție" value={documentContext.telefon ?? ''}
onChange={(e) => setDocContextField('telefon', e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' } }} />
<TextInput label="Fax" value={documentContext.fax ?? ''}
onChange={(e) => setDocContextField('fax', e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' } }} />
<TextInput label="E-mail" value={documentContext.email ?? ''}
onChange={(e) => setDocContextField('email', e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' } }} />
<TextInput label="Solicitant" value={documentContext.solicitant ?? ''}
onChange={(e) => setDocContextField('solicitant', e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' } }} />
<TextInput label="Funcția solicitantului" value={documentContext.functia ?? ''}
onChange={(e) => setDocContextField('functia', e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.75rem' } }} />
</SimpleGrid>
</Paper>
<Paper p={12} style={{ background: '#e6f4f4', borderRadius: 6 }}>
<Text size="xs" style={{ fontFamily: font, fontWeight: 500, color: teal }}>
Documente care vor fi generate:
</Text>
<Text size="xs" style={{ fontFamily: font, fontWeight: 300, color: charcoal, marginTop: 4 }}>
Fișa de evaluare a factorilor de risc (Anexa 4) per grup{'\n'}
Fișa de solicitare a examenului medical per grup{'\n'}
Supliment radiații (Anexa 4B) dacă aplicabil{'\n'}
Fișa de aptitudine în muncă (Anexa 6) per angajat
</Text>
</Paper>
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={() => setModalOpen(false)} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button type="submit" loading={bulkMutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
Generează documente
</Button>
</Group>
</form>
</Box>
</Modal>
{/* Result Modal */}
<Modal
opened={!!resultModal}
onClose={() => setResultModal(null)}
title={<Group gap={8}><IconCheck size={18} color={teal} stroke={2.5} /><Text style={{ fontFamily: font, fontWeight: 700, color: teal }}>Documente generate cu succes</Text></Group>}
size="lg"
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
{resultModal && (
<Stack gap={12} mt={8}>
<Group gap={24}>
<Box>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase' }}>Batch ID</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>{resultModal.batchId.slice(0, 8)}...</Text>
</Box>
<Box>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase' }}>Grupuri risc</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: 700, color: teal }}>{resultModal.groupsCount}</Text>
</Box>
<Box>
<Text size="xs" c="#adb5bd" style={{ fontFamily: font, fontWeight: 600, textTransform: 'uppercase' }}>Angajați</Text>
<Text size="sm" style={{ fontFamily: font, fontWeight: 700, color: teal }}>{resultModal.employeesCount}</Text>
</Box>
</Group>
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', maxHeight: 300, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
<th style={{ fontFamily: font, fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: charcoal, padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}` }}>Checkup ID</th>
<th style={{ fontFamily: font, fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: charcoal, padding: '10px 14px', textAlign: 'left', borderBottom: `2px solid ${teal}` }}>Documente</th>
</tr>
</thead>
<tbody>
{resultModal.checkups.map((c) => (
<tr key={c.checkupId} style={{ borderBottom: `1px solid ${border}` }}>
<td style={{ padding: '8px 14px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{c.checkupId.slice(0, 8)}...
</td>
<td style={{ padding: '8px 14px' }}>
<Group gap={4} wrap="wrap">
{c.documents.map((d, i) => (
<Badge key={i} size="xs" variant="light" color={d.type === 'docx' ? 'teal' : 'orange'} style={{ fontFamily: font }}>
{d.name}
</Badge>
))}
</Group>
</td>
</tr>
))}
</tbody>
</table>
</Paper>
<Group justify="flex-end">
<Button onClick={() => setResultModal(null)} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
Închide
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
}
@@ -0,0 +1,259 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper, Select, Textarea, TextInput,
Loader, Center, Badge, Modal, LoadingOverlay,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import { apiClient } from '../../api/client';
import type { MedicalCheckup, MedicalVerdict } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
const VERDICT_OPTIONS: { value: MedicalVerdict; label: string; color: string }[] = [
{ value: 'apt', label: 'Apt', color: 'teal' },
{ value: 'apt_perioada_adaptare', label: 'Apt (perioadă adaptare)', color: 'cyan' },
{ value: 'apt_conditionat', label: 'Apt condiționat', color: 'orange' },
{ value: 'inapt_temporar', label: 'Inapt temporar', color: 'yellow' },
{ value: 'inapt', label: 'Inapt', color: 'red' },
];
const CHECKUP_TYPE_LABELS: Record<string, string> = {
la_angajare: 'La angajare',
periodic: 'Periodic',
la_reluarea_activitatii: 'La reluarea activității',
la_incetarea_expunerii: 'La încetarea expunerii',
suplimentar: 'Suplimentar',
};
export function MedicalInboxPage() {
const qc = useQueryClient();
const [selected, setSelected] = useState<MedicalCheckup | null>(null);
const [verdict, setVerdict] = useState<MedicalVerdict | null>(null);
const [dataEfectuata, setDataEfectuata] = useState<Date | null>(new Date());
const [valabilPanaLa, setValabilPanaLa] = useState<Date | null>(null);
const [recomandari, setRecomandari] = useState('');
const [semnatDe, setSemnatDe] = useState('');
const { data: pending, isLoading } = useQuery({
queryKey: ['medical-inbox'],
queryFn: () => apiClient.get<MedicalCheckup[]>('/medical/checkups/inbox/pending').then((r) => r.data),
});
const completeMutation = useMutation({
mutationFn: ({ id, ...dto }: {
id: string;
verdict: MedicalVerdict;
dataEfectuata: string;
recomandari?: string;
valabilPanaLa?: string;
semnatDe?: string;
}) =>
apiClient.patch(`/medical/checkups/${id}/complete`, dto),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['medical-inbox'] });
notifications.show({ color: 'medpark', title: 'Completat', message: 'Verdictul a fost înregistrat.' });
closeModal();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
function openComplete(c: MedicalCheckup) {
setSelected(c);
setVerdict(null);
setDataEfectuata(new Date());
setValabilPanaLa(null);
setRecomandari('');
setSemnatDe('');
}
function closeModal() {
setSelected(null);
setVerdict(null);
setRecomandari('');
setValabilPanaLa(null);
setSemnatDe('');
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected || !verdict || !dataEfectuata) return;
completeMutation.mutate({
id: selected.id,
verdict,
dataEfectuata: dayjs(dataEfectuata).format('YYYY-MM-DD'),
recomandari: recomandari || undefined,
valabilPanaLa: valabilPanaLa ? dayjs(valabilPanaLa).format('YYYY-MM-DD') : undefined,
semnatDe: semnatDe.trim() || undefined,
});
}
return (
<Stack gap={24}>
<Box>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.5rem', marginBottom: 4 }}>
Inbox medic de familie
</Title>
<Text size="sm" c="#adb5bd" style={{ fontFamily: font, fontWeight: 300 }}>
Controale medicale planificate în așteptarea verdictului
</Text>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 6 }} />
</Box>
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<table className="brand-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['IDNP', 'Angajat', 'Tip control', 'Data planificată', 'Card de risc', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal,
padding: '14px 16px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody className="hrm-stagger">
{!pending?.length ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 48, fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Niciun control medical în așteptare.
</td></tr>
) : pending.map((c) => {
const overdue = dayjs().isAfter(dayjs(c.dataPlanificata));
return (
<tr key={c.id} style={{ borderBottom: `1px solid ${border}`, background: overdue ? '#fff5f5' : 'transparent' }}>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{c.employee?.idnp ?? '—'}
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{c.employee?.nume} {c.employee?.prenume}
</td>
<td style={{ padding: '13px 16px' }}>
<Badge size="xs" variant="light" color="teal" style={{ fontFamily: font }}>
{CHECKUP_TYPE_LABELS[c.tip] ?? c.tip}
</Badge>
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: overdue ? '#b11116' : charcoal }}>
{overdue && <span className="hrm-pulse-dot" style={{ marginRight: 6 }} />}
{dayjs(c.dataPlanificata).format('DD.MM.YYYY')}
{overdue && <span style={{ marginLeft: 6, fontSize: '0.7rem', color: '#b11116' }}>întârziat</span>}
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.8rem', color: charcoal }}>
{c.employee?.medicalProfile?.workplaceRiskCard?.name ?? '—'}
</td>
<td style={{ padding: '13px 16px', textAlign: 'right' }}>
<Button size="xs" onClick={() => openComplete(c)}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
Completează
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Paper>
{/* Complete Checkup Modal */}
<Modal
opened={!!selected}
onClose={closeModal}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>Finalizare control medical</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
size="md"
>
{selected && (
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={completeMutation.isPending} />
<Paper p={14} mb={16} style={{ background: '#e6f4f4', borderRadius: 6 }}>
<Text size="sm" style={{ fontFamily: font, fontWeight: 600, color: teal }}>
{selected.employee?.nume} {selected.employee?.prenume}
</Text>
<Text size="xs" style={{ fontFamily: font, fontWeight: 300, color: charcoal }}>
{CHECKUP_TYPE_LABELS[selected.tip]} · planificat {dayjs(selected.dataPlanificata).format('DD.MM.YYYY')}
</Text>
</Paper>
<form onSubmit={handleSubmit}>
<Stack gap={14}>
<DateInput
label="Data efectuării *"
value={dataEfectuata}
onChange={setDataEfectuata}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
required
/>
<Select
label="Verdict *"
data={VERDICT_OPTIONS.map((v) => ({ value: v.value, label: v.label }))}
value={verdict}
onChange={(v) => setVerdict(v as MedicalVerdict)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
required
/>
{verdict && verdict !== 'apt' && (
<Textarea
label="Recomandări medicale"
placeholder="Descrieți restricțiile sau condițiile de aptitudine..."
minRows={3}
value={recomandari}
onChange={(e) => setRecomandari(e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)}
<DateInput
label="Valabil până la"
value={valabilPanaLa}
onChange={setValabilPanaLa}
valueFormat="DD.MM.YYYY"
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
<TextInput
label="Semnat de"
placeholder="Nume medic"
value={semnatDe}
onChange={(e) => setSemnatDe(e.currentTarget.value)}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}` }}>
<Button variant="subtle" onClick={closeModal} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button
type="submit"
loading={completeMutation.isPending}
disabled={!verdict || !dataEfectuata}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}
>
Înregistrează verdict
</Button>
</Group>
</form>
</Box>
)}
</Modal>
</Stack>
);
}
@@ -0,0 +1,644 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Title, Box, Text, Button, Group, Stack, Paper,
Modal, TextInput, Textarea, NumberInput, Select, Checkbox, Switch, Divider,
LoadingOverlay, Loader, Center, ActionIcon, Tooltip, SimpleGrid,
} from '@mantine/core';
import { IconPencil, IconTrash, IconPlus } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { apiClient } from '../../api/client';
import type { WorkplaceRiskCard, RiskExposure, RiskExposureType } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const charcoal = '#58595b';
const teal = '#008286';
const border = '#e9ecef';
// ── Anexa 4 field catalogs (chei sincronizate cu șablonul DOCX din seed.ts) ──
const EVAL_GROUPS: { title: string; items: { key: string; label: string }[] }[] = [
{ title: 'Descrierea activității', items: [
{ key: 'echipa', label: 'Lucru în echipă' },
{ key: 'schimbNoapte', label: 'Schimb de noapte' },
{ key: 'pauzeOrganizate', label: 'Pauze organizate' },
] },
{ title: 'Riscuri', items: [
{ key: 'riscInfectare', label: 'Infectare' },
{ key: 'riscElectrocutare', label: 'Electrocutare' },
{ key: 'riscTensiuneInalta', label: 'Tensiune înaltă' },
{ key: 'riscInecare', label: 'Înecare' },
{ key: 'riscAsfixiere', label: 'Asfixiere' },
{ key: 'riscStrivire', label: 'Strivire' },
{ key: 'riscTaiere', label: 'Tăiere' },
{ key: 'riscIntepare', label: 'Înțepare' },
{ key: 'riscLovire', label: 'Lovire' },
{ key: 'riscMuscatura', label: 'Mușcătură' },
{ key: 'riscMicrotraumatisme', label: 'Microtraumatisme repetate' },
] },
{ title: 'Conducere / spațiu de lucru', items: [
{ key: 'conduceMasina', label: 'Conduce mașina instituției' },
{ key: 'conduceUtilajeIntrauzinal', label: 'Conduce utilaje intrauzinal' },
{ key: 'suprafataVerticala', label: 'Suprafață verticală' },
{ key: 'suprafataOrizontala', label: 'Suprafață orizontală' },
{ key: 'suprafataOblica', label: 'Suprafață oblică' },
{ key: 'muncaIzolare', label: 'Muncă în izolare' },
{ key: 'muncaInaltime', label: 'Muncă la înălțime' },
{ key: 'muncaInMiscare', label: 'Muncă în mișcare' },
] },
{ title: 'Efort fizic', items: [
{ key: 'pozitieOrtostatica', label: 'Poziție ortostatică' },
{ key: 'pozitieAsezat', label: 'Poziție așezat' },
{ key: 'pozitieAplecata', label: 'Poziție aplecată' },
{ key: 'pozitieMixta', label: 'Poziție mixtă' },
{ key: 'pozitieFortata', label: 'Poziție forțată' },
{ key: 'coloanaCervicala', label: 'Suprasolicitare coloană cervicală' },
{ key: 'coloanaToracala', label: 'Suprasolicitare coloană toracală' },
{ key: 'coloanaLombara', label: 'Suprasolicitare coloană lombară' },
{ key: 'manipulareRidicare', label: 'Manipulare: ridicare' },
{ key: 'manipulareCoborare', label: 'Manipulare: coborâre' },
{ key: 'manipulareImpingere', label: 'Manipulare: împingere' },
{ key: 'manipulareTragere', label: 'Manipulare: tragere' },
{ key: 'manipularePurtare', label: 'Manipulare: purtare' },
{ key: 'manipulareDeplasare', label: 'Manipulare: deplasare' },
{ key: 'suprasolicitariVizuale', label: 'Suprasolicitări vizuale' },
{ key: 'suprasolicitariAuditive', label: 'Suprasolicitări auditive' },
{ key: 'suprasolicitariNeuropsihice', label: 'Suprasolicitări neuropsihice' },
] },
{ title: 'Microclimat / iluminat', items: [
{ key: 'microclimatInterior', label: 'Lucrări interior' },
{ key: 'microclimatExterior', label: 'Lucru exterior' },
{ key: 'radiatiiCaloriceRece', label: 'Radiații calorice (perioada rece)' },
{ key: 'radiatiiCaloriceCalda', label: 'Radiații calorice (perioada caldă)' },
{ key: 'iluminatSuficient', label: 'Iluminat suficient' },
{ key: 'iluminatInsuficient', label: 'Iluminat insuficient' },
{ key: 'iluminatNatural', label: 'Iluminat natural' },
{ key: 'iluminatArtificial', label: 'Iluminat artificial' },
{ key: 'iluminatMixt', label: 'Iluminat mixt' },
] },
];
const EVAL_TEXT: { key: string; label: string; placeholder?: string }[] = [
{ key: 'oreZi', label: 'Nr. ore/zi' },
{ key: 'schimburi', label: 'Nr. schimburi' },
{ key: 'conduceMasinaCategorie', label: 'Categorie conducere' },
{ key: 'spatiuL', label: 'Dimensiune L (m)' },
{ key: 'spatiul', label: 'Dimensiune l (m)' },
{ key: 'spatiuH', label: 'Dimensiune H (m)' },
{ key: 'greutateMaxima', label: 'Greutate maximă manipulată' },
];
// Câmpuri suplimentare Anexa 4A (muncă la distanță / platforme digitale)
const EVAL_4A_CHECKS: { key: string; label: string }[] = [
{ key: 'lucruMonitor', label: 'Lucru la monitor' },
{ key: 'platformeDigitale', label: 'Lucru pe platforme digitale' },
{ key: 'deplasari', label: 'Deplasări pe teren' },
];
const EVAL_4A_TEXT: { key: string; label: string }[] = [
{ key: 'operatiuni', label: 'Operațiuni executate' },
{ key: 'deplasariDescriere', label: 'Descrierea deplasărilor' },
{ key: 'alteRiscuri', label: 'Alte riscuri' },
];
const ANEXE: { key: string; label: string }[] = [
{ key: 'vestiar', label: 'Vestiar' },
{ key: 'chiuveta', label: 'Chiuvetă' },
{ key: 'wc', label: 'WC' },
{ key: 'dus', label: 'Duș' },
{ key: 'salaMese', label: 'Sală de mese' },
{ key: 'recreere', label: 'Spațiu de recreere' },
];
const EXPOSURE_TIPS: { value: RiskExposureType; label: string }[] = [
{ value: 'AGENT_CHIMIC', label: 'Agent chimic' },
{ value: 'PULBERI', label: 'Pulberi' },
{ value: 'AGENT_BIOLOGIC', label: 'Agent biologic' },
{ value: 'ZGOMOT', label: 'Zgomot profesional' },
{ value: 'VIBRATII', label: 'Vibrații mecanice' },
{ value: 'CAMP_ELECTROMAGNETIC', label: 'Câmp electromagnetic' },
{ value: 'RADIATII_OPTICE', label: 'Radiații optice artificiale' },
];
interface FormState {
name: string;
tipFisa: string;
filiala: string;
adresaFiliala: string;
telefonFiliala: string;
caemPrimeleDouaCifre: string;
cormSubgrupaMajora: string;
directiaSectiaSectorul: string;
numarulLoculuiDeMunca: string;
caemDiviziune: string;
clasaConditiilorDeMunca: string;
numarLucratoriPosibili: string;
evaluareDetalii: Record<string, boolean | string>;
radiatiiIonizante: boolean;
radiatiiGrupa: string;
radiatiiSurse: string;
radiatiiTipExpunere: string;
radiatiiAparatura: string;
radiatiiMasuriProtectie: string;
mijloaceProtectieColectiva: string;
mijloaceProtectieIndividuala: string;
echipamentLucru: string;
observatii: string;
anexeIgienicoSanitare: Record<string, boolean>;
exposures: RiskExposure[];
}
function recordToEval(ed: Record<string, unknown> | null | undefined): Record<string, boolean | string> {
const out: Record<string, boolean | string> = {};
for (const g of EVAL_GROUPS) for (const it of g.items) out[it.key] = ed?.[it.key] === true;
for (const c of EVAL_4A_CHECKS) out[c.key] = ed?.[c.key] === true;
for (const t of [...EVAL_TEXT, ...EVAL_4A_TEXT]) out[t.key] = ed?.[t.key] != null ? String(ed[t.key]) : '';
return out;
}
function recordToAnexe(an: Record<string, unknown> | null | undefined): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const a of ANEXE) out[a.key] = an?.[a.key] === true;
return out;
}
function makeForm(card?: WorkplaceRiskCard | null): FormState {
return {
name: card?.name ?? '',
tipFisa: card?.tipFisa ?? 'STANDARD',
filiala: card?.filiala ?? '',
adresaFiliala: card?.adresaFiliala ?? '',
telefonFiliala: card?.telefonFiliala ?? '',
caemPrimeleDouaCifre: card?.caemPrimeleDouaCifre ?? '',
cormSubgrupaMajora: card?.cormSubgrupaMajora ?? '',
directiaSectiaSectorul: card?.directiaSectiaSectorul ?? '',
numarulLoculuiDeMunca: card?.numarulLoculuiDeMunca ?? '',
caemDiviziune: card?.caemDiviziune ?? '',
clasaConditiilorDeMunca: card?.clasaConditiilorDeMunca ?? '',
numarLucratoriPosibili: card?.numarLucratoriPosibili != null ? String(card.numarLucratoriPosibili) : '',
evaluareDetalii: recordToEval(card?.evaluareDetalii),
radiatiiIonizante: card?.radiatiiIonizante ?? false,
radiatiiGrupa: card?.radiatiiGrupa ?? '',
radiatiiSurse: card?.radiatiiSurse ?? '',
radiatiiTipExpunere: card?.radiatiiTipExpunere ?? '',
radiatiiAparatura: card?.radiatiiAparatura ?? '',
radiatiiMasuriProtectie: card?.radiatiiMasuriProtectie ?? '',
mijloaceProtectieColectiva: card?.mijloaceProtectieColectiva ?? '',
mijloaceProtectieIndividuala: card?.mijloaceProtectieIndividuala ?? '',
echipamentLucru: card?.echipamentLucru ?? '',
observatii: card?.observatii ?? '',
anexeIgienicoSanitare: recordToAnexe(card?.anexeIgienicoSanitare),
exposures: (card?.exposures ?? []).map((e) => ({ ...e })),
};
}
const u = (s: string) => (s.trim() ? s.trim() : undefined);
function buildPayload(form: FormState): Record<string, unknown> {
const evaluareDetalii: Record<string, unknown> = {};
for (const [k, v] of Object.entries(form.evaluareDetalii)) {
if (v === true) evaluareDetalii[k] = true;
else if (typeof v === 'string' && v.trim()) evaluareDetalii[k] = v.trim();
}
const anexeIgienicoSanitare: Record<string, boolean> = {};
for (const [k, v] of Object.entries(form.anexeIgienicoSanitare)) if (v) anexeIgienicoSanitare[k] = true;
return {
name: form.name.trim(),
tipFisa: form.tipFisa,
filiala: u(form.filiala),
adresaFiliala: u(form.adresaFiliala),
telefonFiliala: u(form.telefonFiliala),
caemPrimeleDouaCifre: u(form.caemPrimeleDouaCifre),
cormSubgrupaMajora: u(form.cormSubgrupaMajora),
directiaSectiaSectorul: u(form.directiaSectiaSectorul),
numarulLoculuiDeMunca: u(form.numarulLoculuiDeMunca),
caemDiviziune: u(form.caemDiviziune),
clasaConditiilorDeMunca: u(form.clasaConditiilorDeMunca),
numarLucratoriPosibili: form.numarLucratoriPosibili ? Number(form.numarLucratoriPosibili) : undefined,
evaluareDetalii,
radiatiiIonizante: form.radiatiiIonizante,
radiatiiGrupa: u(form.radiatiiGrupa),
radiatiiSurse: u(form.radiatiiSurse),
radiatiiTipExpunere: u(form.radiatiiTipExpunere),
radiatiiAparatura: u(form.radiatiiAparatura),
radiatiiMasuriProtectie: u(form.radiatiiMasuriProtectie),
mijloaceProtectieColectiva: u(form.mijloaceProtectieColectiva),
mijloaceProtectieIndividuala: u(form.mijloaceProtectieIndividuala),
echipamentLucru: u(form.echipamentLucru),
observatii: u(form.observatii),
anexeIgienicoSanitare,
exposures: form.exposures
.filter((e) => e.denumire.trim())
.map((e) => ({
tip: e.tip,
denumire: e.denumire.trim(),
cas: u(e.cas ?? ''),
einecs: u(e.einecs ?? ''),
clasificare: u(e.clasificare ?? ''),
zonaAfectata: u(e.zonaAfectata ?? ''),
timpExpunere: u(e.timpExpunere ?? ''),
vep: u(e.vep ?? ''),
vlep: u(e.vlep ?? ''),
caracteristici: u(e.caracteristici ?? ''),
})),
};
}
const labelStyles = { label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } };
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<Text style={{ fontFamily: font, fontWeight: 700, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: teal }}>
{children}
</Text>
);
}
export function RiskCardsPage() {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(makeForm());
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data: cards, isLoading } = useQuery({
queryKey: ['risk-cards'],
queryFn: () => apiClient.get<WorkplaceRiskCard[]>('/medical/risk-cards').then((r) => r.data),
});
const { data: expandedCard } = useQuery({
queryKey: ['risk-card', expandedId],
queryFn: () => apiClient.get<WorkplaceRiskCard>(`/medical/risk-cards/${expandedId}`).then((r) => r.data),
enabled: !!expandedId,
});
const saveMutation = useMutation({
mutationFn: (payload: Record<string, unknown>) =>
editId
? apiClient.patch(`/medical/risk-cards/${editId}`, payload)
: apiClient.post('/medical/risk-cards', payload),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['risk-cards'] });
await qc.invalidateQueries({ queryKey: ['risk-card'] });
notifications.show({ color: 'medpark', title: 'Salvat', message: editId ? 'Cardul actualizat.' : 'Card creat.' });
closeModal();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: Array.isArray(msg) ? msg.join('; ') : msg });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/medical/risk-cards/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['risk-cards'] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Cardul a fost șters.' });
},
});
function openCreate() { setEditId(null); setForm(makeForm()); setModalOpen(true); }
function openEdit(card: WorkplaceRiskCard) { setEditId(card.id); setForm(makeForm(card)); setModalOpen(true); }
function closeModal() { setModalOpen(false); setEditId(null); setForm(makeForm()); }
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
saveMutation.mutate(buildPayload(form));
}
// exposure helpers
const addExposure = () =>
setForm((f) => ({ ...f, exposures: [...f.exposures, { tip: 'AGENT_CHIMIC', denumire: '' }] }));
const removeExposure = (i: number) =>
setForm((f) => ({ ...f, exposures: f.exposures.filter((_, idx) => idx !== i) }));
const updateExposure = (i: number, patch: Partial<RiskExposure>) =>
setForm((f) => ({ ...f, exposures: f.exposures.map((e, idx) => (idx === i ? { ...e, ...patch } : e)) }));
const factorCount = (card: WorkplaceRiskCard) =>
card.exposures?.length ??
(card.riskFactors ? Object.values(card.riskFactors).reduce((s, arr) => s + ((arr as string[] | undefined)?.length ?? 0), 0) : 0);
const setEval = (key: string, value: boolean | string) =>
setForm((f) => ({ ...f, evaluareDetalii: { ...f.evaluareDetalii, [key]: value } }));
return (
<Stack gap={24}>
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2} style={{ fontFamily: font, fontWeight: 700, color: charcoal, fontSize: '1.5rem', marginBottom: 4 }}>
Carduri de risc la locul de muncă
</Title>
<Text size="sm" c="#adb5bd" style={{ fontFamily: font, fontWeight: 300 }}>
Fișa de evaluare a riscurilor profesionale Anexa 4 din NU-10-MS-2026
</Text>
<Box style={{ width: 40, height: 3, background: teal, borderRadius: 2, marginTop: 6 }} />
</Box>
<Button onClick={openCreate} style={{ background: teal, fontFamily: font, fontWeight: 500, height: 40, paddingLeft: 20, paddingRight: 20 }}>
+ Card nou
</Button>
</Group>
<Paper shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
{isLoading ? (
<Center h={200}><Loader color="medpark" size="sm" /></Center>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#fafafa' }}>
{['Denumire', 'Factori risc', 'Angajați', ''].map((h, i) => (
<th key={i} style={{
fontFamily: font, fontWeight: 600, fontSize: '0.7rem',
textTransform: 'uppercase', letterSpacing: '0.07em', color: charcoal,
padding: '14px 16px', textAlign: 'left', borderBottom: `2px solid ${teal}`,
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{!cards?.length ? (
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 48, fontFamily: font, fontWeight: 300, color: '#adb5bd' }}>
Niciun card de risc. Creați primul card.
</td></tr>
) : cards.map((card) => (
<tr key={card.id}
style={{ borderBottom: `1px solid ${border}`, cursor: 'pointer' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = '#e6f4f4')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = 'transparent')}
onClick={() => setExpandedId(expandedId === card.id ? null : card.id)}
>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: charcoal }}>
{card.name}
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 300, fontSize: '0.875rem', color: charcoal }}>
{factorCount(card)} factori
</td>
<td style={{ padding: '13px 16px', fontFamily: font, fontWeight: 500, fontSize: '0.875rem', color: teal }}>
{card._count?.profiles ?? 0}
</td>
<td style={{ padding: '13px 16px', textAlign: 'right' }}>
<Group gap={8} justify="flex-end">
<Tooltip label="Editează">
<ActionIcon variant="subtle" color="teal" size="sm" onClick={(e) => { e.stopPropagation(); openEdit(card); }}>
<IconPencil size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label="Șterge">
<ActionIcon variant="subtle" color="red" size="sm"
onClick={(e) => { e.stopPropagation(); if (confirm('Sigur doriți să ștergeți?')) deleteMutation.mutate(card.id); }}>
<IconTrash size={14} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
</td>
</tr>
))}
</tbody>
</table>
)}
</Paper>
{/* Expanded card detail */}
{expandedId && expandedCard && (
<Paper p={20} shadow="none" style={{ border: `1px solid ${border}`, borderRadius: 8, background: '#fff' }}>
<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal, marginBottom: 8 }}>
{expandedCard.name}
</Text>
<Group gap={20} mb={12} wrap="wrap">
{expandedCard.cormSubgrupaMajora && <Text size="xs" c="#6c757d" style={{ fontFamily: font }}>CORM: {expandedCard.cormSubgrupaMajora}</Text>}
{expandedCard.numarulLoculuiDeMunca && <Text size="xs" c="#6c757d" style={{ fontFamily: font }}>Loc: {expandedCard.numarulLoculuiDeMunca}</Text>}
{expandedCard.caemDiviziune && <Text size="xs" c="#6c757d" style={{ fontFamily: font }}>CAEM: {expandedCard.caemDiviziune}</Text>}
{expandedCard.clasaConditiilorDeMunca && <Text size="xs" c="#6c757d" style={{ fontFamily: font }}>Clasa: {expandedCard.clasaConditiilorDeMunca}</Text>}
{expandedCard.radiatiiIonizante && <Text size="xs" fw={600} c="#e8590c" style={{ fontFamily: font }}>Radiații ionizante</Text>}
</Group>
{expandedCard.exposures && expandedCard.exposures.length > 0 && (
<Box mb={12}>
<SectionTitle>Factori de risc ({expandedCard.exposures.length})</SectionTitle>
<Stack gap={2} mt={4}>
{expandedCard.exposures.map((e) => (
<Text key={e.id} size="sm" c={charcoal} style={{ fontFamily: font, fontWeight: 300 }}>
[{EXPOSURE_TIPS.find((t) => t.value === e.tip)?.label ?? e.tip}] {e.denumire}
{e.vlep ? ` — VLEP: ${e.vlep}` : ''}
</Text>
))}
</Stack>
</Box>
)}
{expandedCard.profiles && expandedCard.profiles.length > 0 && (
<Box mt={12}>
<SectionTitle>Angajați atribuiți ({expandedCard.profiles.length})</SectionTitle>
<Group gap={6} wrap="wrap" mt={4}>
{expandedCard.profiles.map((p) => (
<Box key={p.id} style={{
background: '#e6f4f4', borderRadius: 20, padding: '3px 10px',
fontFamily: font, fontWeight: 500, fontSize: '0.75rem', color: teal,
}}>
{p.employee.nume} {p.employee.prenume}
</Box>
))}
</Group>
</Box>
)}
</Paper>
)}
{/* Create / Edit Modal */}
<Modal
opened={modalOpen}
onClose={closeModal}
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>{editId ? 'Editare card de risc' : 'Card de risc nou'}</Text>}
size="xl"
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={saveMutation.isPending} />
<form onSubmit={handleSubmit}>
<Stack gap={18} mt={4}>
{/* ── Date generale ── */}
<SectionTitle>Date generale (antet Anexa 4)</SectionTitle>
<TextInput
label="Denumire tip loc de muncă *"
placeholder="Ex: Asistent medical cu gărzi de noapte"
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
styles={labelStyles}
required
/>
<Select
label="Tipul fișei"
data={[
{ value: 'STANDARD', label: 'Anexa 4 — standard' },
{ value: 'DISTANTA_DIGITAL', label: 'Anexa 4A — muncă la distanță / platforme digitale' },
]}
value={form.tipFisa}
allowDeselect={false}
onChange={(v) => v && setForm((f) => ({ ...f, tipFisa: v }))}
styles={labelStyles}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={12}>
<TextInput label="Ocupația (subgrupa majoră CORM)" value={form.cormSubgrupaMajora}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, cormSubgrupaMajora: v })); }} styles={labelStyles} />
<TextInput label="Direcția/secția/sectorul" value={form.directiaSectiaSectorul}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, directiaSectiaSectorul: v })); }} styles={labelStyles} />
<TextInput label="Numărul locului de muncă" value={form.numarulLoculuiDeMunca}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, numarulLoculuiDeMunca: v })); }} styles={labelStyles} />
<TextInput label="CAEM (nivel diviziune)" value={form.caemDiviziune}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, caemDiviziune: v })); }} styles={labelStyles} />
<TextInput label="Filiala" value={form.filiala}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, filiala: v })); }} styles={labelStyles} />
<TextInput label="Adresa filialei" value={form.adresaFiliala}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, adresaFiliala: v })); }} styles={labelStyles} />
<TextInput label="Telefon filială" value={form.telefonFiliala}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, telefonFiliala: v })); }} styles={labelStyles} />
<TextInput label="CAEM (primele 2 cifre)" value={form.caemPrimeleDouaCifre}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, caemPrimeleDouaCifre: v })); }} styles={labelStyles} />
<TextInput label="Clasa condițiilor de muncă" value={form.clasaConditiilorDeMunca}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, clasaConditiilorDeMunca: v })); }} styles={labelStyles} />
<NumberInput label="Nr. lucrători care pot activa" min={0} value={form.numarLucratoriPosibili === '' ? '' : Number(form.numarLucratoriPosibili)}
onChange={(v) => setForm((f) => ({ ...f, numarLucratoriPosibili: v === '' || v == null ? '' : String(v) }))} styles={labelStyles} />
</SimpleGrid>
<Divider />
{/* ── Bloc descriptiv ── */}
<SectionTitle>Descrierea activității și efort fizic</SectionTitle>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing={8}>
{EVAL_TEXT.map((t) => (
<TextInput key={t.key} label={t.label} value={(form.evaluareDetalii[t.key] as string) ?? ''}
onChange={(e) => { const v = e.currentTarget.value; setEval(t.key, v); }} styles={labelStyles} />
))}
</SimpleGrid>
{EVAL_GROUPS.map((g) => (
<Box key={g.title}>
<Text size="xs" fw={600} c={charcoal} mb={6} style={{ fontFamily: font }}>{g.title}</Text>
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing={6}>
{g.items.map((it) => (
<Checkbox key={it.key} label={it.label} color="medpark" size="xs"
checked={form.evaluareDetalii[it.key] === true}
onChange={(e) => setEval(it.key, e.currentTarget.checked)}
styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.78rem' } }} />
))}
</SimpleGrid>
</Box>
))}
{form.tipFisa === 'DISTANTA_DIGITAL' && (
<Box>
<Text size="xs" fw={600} c={charcoal} mb={6} style={{ fontFamily: font }}>Muncă la distanță / platforme digitale (Anexa 4A)</Text>
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing={6} mb={8}>
{EVAL_4A_CHECKS.map((it) => (
<Checkbox key={it.key} label={it.label} color="medpark" size="xs"
checked={form.evaluareDetalii[it.key] === true}
onChange={(e) => setEval(it.key, e.currentTarget.checked)}
styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.78rem' } }} />
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing={8}>
{EVAL_4A_TEXT.map((t) => (
<TextInput key={t.key} label={t.label} value={(form.evaluareDetalii[t.key] as string) ?? ''}
onChange={(e) => { const v = e.currentTarget.value; setEval(t.key, v); }} styles={labelStyles} />
))}
</SimpleGrid>
</Box>
)}
<Divider />
{/* ── Factori de risc (tabele) ── */}
<Group justify="space-between">
<SectionTitle>Factori de risc (tabele Anexa 4)</SectionTitle>
<Button size="xs" variant="light" color="teal" leftSection={<IconPlus size={14} />} onClick={addExposure}
style={{ fontFamily: font }}>
Adaugă factor
</Button>
</Group>
{form.exposures.length === 0 && (
<Text size="xs" c="#adb5bd" style={{ fontFamily: font }}>Niciun factor adăugat.</Text>
)}
<Stack gap={10}>
{form.exposures.map((e, i) => (
<Paper key={i} p={12} withBorder style={{ borderColor: border, borderRadius: 8 }}>
<Group gap={8} mb={8} align="flex-end" wrap="nowrap">
<Select label="Tip" data={EXPOSURE_TIPS} value={e.tip} allowDeselect={false}
onChange={(v) => v && updateExposure(i, { tip: v as RiskExposureType })}
styles={labelStyles} style={{ width: 220 }} />
<TextInput label="Denumire *" placeholder="Ex: Glutaraldehidă" value={e.denumire}
onChange={(ev) => updateExposure(i, { denumire: ev.currentTarget.value })}
styles={labelStyles} style={{ flex: 1 }} />
<Tooltip label="Elimină">
<ActionIcon variant="subtle" color="red" onClick={() => removeExposure(i)} mb={4}>
<IconTrash size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={8}>
<TextInput label="CAS" value={e.cas ?? ''} onChange={(ev) => updateExposure(i, { cas: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="EINECS" value={e.einecs ?? ''} onChange={(ev) => updateExposure(i, { einecs: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="Clasificare" value={e.clasificare ?? ''} onChange={(ev) => updateExposure(i, { clasificare: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="Zona afectată" value={e.zonaAfectata ?? ''} onChange={(ev) => updateExposure(i, { zonaAfectata: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="Timp expunere" value={e.timpExpunere ?? ''} onChange={(ev) => updateExposure(i, { timpExpunere: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="VEP" value={e.vep ?? ''} onChange={(ev) => updateExposure(i, { vep: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="VLEP" value={e.vlep ?? ''} onChange={(ev) => updateExposure(i, { vlep: ev.currentTarget.value })} styles={labelStyles} />
<TextInput label="Caracteristici" value={e.caracteristici ?? ''} onChange={(ev) => updateExposure(i, { caracteristici: ev.currentTarget.value })} styles={labelStyles} />
</SimpleGrid>
</Paper>
))}
</Stack>
<Divider />
{/* ── Radiații ionizante ── */}
<SectionTitle>Radiații ionizante</SectionTitle>
<Switch label="Loc de muncă expus radiațiilor ionizante" color="medpark"
checked={form.radiatiiIonizante}
onChange={(e) => { const v = e.currentTarget.checked; setForm((f) => ({ ...f, radiatiiIonizante: v })); }}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
{form.radiatiiIonizante && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={12}>
<Select label="Grupa" data={['A', 'B']} value={form.radiatiiGrupa || null} clearable
onChange={(v) => setForm((f) => ({ ...f, radiatiiGrupa: v ?? '' }))} styles={labelStyles} />
<Select label="Surse" data={['închise', 'deschise']} value={form.radiatiiSurse || null} clearable
onChange={(v) => setForm((f) => ({ ...f, radiatiiSurse: v ?? '' }))} styles={labelStyles} />
<Select label="Tip de expunere" data={['X externă', 'gamma externă', 'internă', 'externă și internă']}
value={form.radiatiiTipExpunere || null} clearable
onChange={(v) => setForm((f) => ({ ...f, radiatiiTipExpunere: v ?? '' }))} styles={labelStyles} />
<TextInput label="Aparatură folosită" value={form.radiatiiAparatura}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, radiatiiAparatura: v })); }} styles={labelStyles} />
<TextInput label="Măsuri de protecție" value={form.radiatiiMasuriProtectie}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, radiatiiMasuriProtectie: v })); }} styles={labelStyles} />
</SimpleGrid>
)}
<Divider />
{/* ── Protecție / subsol ── */}
<SectionTitle>Protecție și dotări</SectionTitle>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={12}>
<TextInput label="Mijloace de protecție colectivă" value={form.mijloaceProtectieColectiva}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, mijloaceProtectieColectiva: v })); }} styles={labelStyles} />
<TextInput label="Mijloace de protecție individuală" value={form.mijloaceProtectieIndividuala}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, mijloaceProtectieIndividuala: v })); }} styles={labelStyles} />
<TextInput label="Echipament de lucru" value={form.echipamentLucru}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, echipamentLucru: v })); }} styles={labelStyles} />
</SimpleGrid>
<Box>
<Text size="xs" fw={600} c={charcoal} mb={6} style={{ fontFamily: font }}>Anexe igienico-sanitare</Text>
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing={6}>
{ANEXE.map((a) => (
<Checkbox key={a.key} label={a.label} color="medpark" size="xs"
checked={form.anexeIgienicoSanitare[a.key] === true}
onChange={(e) => { const v = e.currentTarget.checked; setForm((f) => ({ ...f, anexeIgienicoSanitare: { ...f.anexeIgienicoSanitare, [a.key]: v } })); }}
styles={{ label: { fontFamily: font, fontWeight: 300, fontSize: '0.78rem' } }} />
))}
</SimpleGrid>
</Box>
<Textarea label="Observații" minRows={2} autosize value={form.observatii}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, observatii: v })); }} styles={labelStyles} />
</Stack>
<Group justify="flex-end" mt={20} pt={16} style={{ borderTop: `1px solid ${border}`, position: 'sticky', bottom: 0, background: '#fff' }}>
<Button variant="subtle" onClick={closeModal} style={{ fontFamily: font, color: charcoal }}>Anulează</Button>
<Button type="submit" loading={saveMutation.isPending} disabled={!form.name.trim()}
style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
{editId ? 'Salvează' : 'Creează'}
</Button>
</Group>
</form>
</Box>
</Modal>
</Stack>
);
}
+267
View File
@@ -0,0 +1,267 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;700&display=swap');
:root {
--color-charcoal: #58595b;
--color-teal: #008286;
--color-teal-light: #e6f4f4;
--color-teal-mid: #2790a5;
--color-amber: #fbb034;
--color-white: #ffffff;
--color-black: #000000;
--color-red: #b11116;
--color-orange: #f15a31;
--color-blue: #27aae1;
--color-border: #e9ecef;
--color-surface: #f8f9fa;
--font-primary: 'Montserrat', Arial, sans-serif;
--nav-width: 280px;
--header-height: 72px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family: var(--font-primary);
color: var(--color-charcoal);
background: var(--color-white);
-webkit-font-smoothing: antialiased;
}
/* Mantine overrides — keep Montserrat everywhere */
.mantine-Text-root,
.mantine-Title-root,
.mantine-Button-root,
.mantine-Input-input,
.mantine-Select-input,
.mantine-Badge-root,
.mantine-Table-root {
font-family: var(--font-primary) !important;
}
/* Teal primary buttons */
.btn-primary {
background: var(--color-teal) !important;
color: var(--color-white) !important;
font-family: var(--font-primary);
font-weight: 500;
border: none;
}
.btn-primary:hover {
background: #006b6e !important;
}
/* Amber CTA — used sparingly */
.btn-cta {
background: var(--color-amber) !important;
color: var(--color-charcoal) !important;
font-weight: 600;
}
/* Table brand styling */
.brand-table thead tr th {
font-family: var(--font-primary);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-charcoal);
border-bottom: 2px solid var(--color-teal) !important;
padding: 14px 18px;
}
.brand-table tbody tr td {
font-family: var(--font-primary);
font-weight: 300;
font-size: 0.9rem;
color: var(--color-charcoal);
padding: 14px 18px;
border-bottom: 1px solid var(--color-border);
}
.brand-table tbody tr:hover td {
background: var(--color-teal-light);
}
/* Active nav item */
.nav-item-active {
border-left: 3px solid var(--color-teal);
background: var(--color-teal-light);
color: var(--color-teal) !important;
}
/* Teal divider */
.teal-divider {
height: 2px;
background: var(--color-teal);
border: none;
margin: 0;
}
/* ───────────────────────────────────────────────────────────
ANIMATIONS — subtle, functional, professional
Goal: enhance perception of speed and responsiveness without
distracting from clinical/HR workflow.
Durations: 120-260ms. Easing: cubic-bezier(0.2, 0.8, 0.2, 1)
(decelerate — feels snappy and natural).
─────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Page content fade + slide-in — applied to <AppShell.Main> wrapper */
@keyframes hrm-page-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.hrm-page {
animation: hrm-page-enter 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Staggered entrance for table rows / list items */
@keyframes hrm-row-enter {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.hrm-stagger > * {
opacity: 0;
animation: hrm-row-enter 220ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
.hrm-stagger > *:nth-child(1) { animation-delay: 20ms; }
.hrm-stagger > *:nth-child(2) { animation-delay: 50ms; }
.hrm-stagger > *:nth-child(3) { animation-delay: 80ms; }
.hrm-stagger > *:nth-child(4) { animation-delay: 110ms; }
.hrm-stagger > *:nth-child(5) { animation-delay: 140ms; }
.hrm-stagger > *:nth-child(6) { animation-delay: 170ms; }
.hrm-stagger > *:nth-child(7) { animation-delay: 200ms; }
.hrm-stagger > *:nth-child(n+8) { animation-delay: 220ms; }
/* Card / Paper hover lift — applied via .hrm-lift */
.hrm-lift {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color 160ms ease;
}
.hrm-lift:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px -8px rgba(0, 130, 134, 0.22);
}
/* Primary button — subtle press effect (don't override .btn-primary base) */
.mantine-Button-root {
transition: transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
box-shadow 160ms ease !important;
}
.mantine-Button-root:active:not([data-disabled]) {
transform: translateY(1px);
}
/* Action icons — gentle rotate/scale on hover */
.mantine-ActionIcon-root {
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 140ms ease,
color 140ms ease !important;
}
.mantine-ActionIcon-root:hover:not([data-disabled]) {
transform: scale(1.08);
}
/* Brand table row — refined hover (overrides plain background swap above) */
.brand-table tbody tr {
transition: background-color 140ms ease, transform 140ms ease;
}
.brand-table tbody tr:hover {
background: var(--color-teal-light);
}
/* Pulsing dot for pending/unread items */
@keyframes hrm-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(177, 17, 22, 0.45); }
50% { box-shadow: 0 0 0 6px rgba(177, 17, 22, 0); }
}
.hrm-pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-red);
animation: hrm-pulse 1.6s ease-in-out infinite;
}
/* Success checkmark stroke draw */
@keyframes hrm-check-draw {
to { stroke-dashoffset: 0; }
}
.hrm-check {
stroke-dasharray: 28;
stroke-dashoffset: 28;
animation: hrm-check-draw 320ms cubic-bezier(0.2, 0.8, 0.2, 1) 80ms forwards;
}
/* Skeleton shimmer for loading lists */
@keyframes hrm-shimmer {
0% { background-position: -240px 0; }
100% { background-position: 240px 0; }
}
.hrm-skeleton {
background: linear-gradient(90deg, #f0f0f0 0%, #fafafa 50%, #f0f0f0 100%);
background-size: 480px 100%;
animation: hrm-shimmer 1.4s linear infinite;
border-radius: 4px;
}
/* Clauza row entrance */
.hrm-clauza-row {
animation: hrm-row-enter 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.hrm-clauza-row:hover {
border-color: #cdd5dc;
box-shadow: 0 2px 8px -4px rgba(0, 130, 134, 0.18);
}
/* Nav link — refined hover (icon nudges right slightly) */
.hrm-nav-link {
transition: background 140ms ease, color 140ms ease, border-color 140ms ease,
padding-left 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hrm-nav-link:hover svg {
transform: translateX(2px);
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hrm-nav-link svg {
transition: transform 160ms ease;
}
/* Badge entrance */
@keyframes hrm-badge-pop {
0% { opacity: 0; transform: scale(0.85); }
60% { transform: scale(1.04); }
100% { opacity: 1; transform: scale(1); }
}
.hrm-badge-pop {
animation: hrm-badge-pop 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Smooth input focus ring */
.mantine-Input-input,
.mantine-Textarea-input {
transition: border-color 140ms ease, box-shadow 140ms ease !important;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': { target: 'http://localhost:3001', changeOrigin: true },
},
},
});