Files
hrm-medpark/apps/web/src/pages/employees/EmployeesPage.tsx
T
Danil Suhomlinov 33800292aa chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:45 +03:00

379 lines
13 KiB
TypeScript

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>
);
}