33800292aa
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|