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:
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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ă"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 să ș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 să ș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 să ș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 să ș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 să ș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 să ș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 că vrei să ș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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user