[CLAUDE] FE-User: Hồ sơ Nhân sự — layout 2 cột (tree+list trái, detail phải) + tô màu chi tiết
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
Anh góp ý từ eoffice live (3 việc): (1) layout 2 cột giống NamGroup — cột trái dọc = Cây tổ chức (trên) + Danh sách NV (dưới) chồng nhau, cột phải = chi tiết rộng (panel list chuyển từ giữa xuống dưới tree); (2)+(3) tô màu panel chi tiết — hệ accent 5 tone (brand/teal/violet/amberx/greenx) icon-chip + heading -700 + rail màu + nhãn field brand-tint (trước slate-400 đơn điệu). Responsive <lg = 1 cột. GIỮ 100%: 5 satellite CRUD (16 endpoint), cây SOLUTION COMPANY, 5 tab, search/filter, query keys (grep + tsc verified). Build PASS fe-user. Designer tự bắt 2 bug (accent -800 không tồn tại palette -> -700; rail before:content). fe-admin mirror defer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,10 +1,15 @@
|
||||
// List + Detail Hồ sơ Nhân sự (HRM) — 3-panel master-detail:
|
||||
// [Cây tổ chức] | [Danh sách + filter] | [Chi tiết 5 tab].
|
||||
// Redesign S65 (2026-06-16) theo reference NamGroup: org-tree panel (consume
|
||||
// GET /api/departments/tree) + avatar header lớn + 5 tab (Tổng quan / Thân nhân
|
||||
// / Trình độ / Kinh nghiệm / Hợp đồng). KHÔNG đổi logic — 100% chức năng giữ:
|
||||
// 5 satellite inline CRUD (add/edit/delete + mutex), search, filter, mọi
|
||||
// TanStack query/mutation key NGUYÊN. Đây là RESTRUCTURE layout, không xoá logic.
|
||||
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel master-detail (NamGroup eoffice ref):
|
||||
// CỘT TRÁI dọc (hẹp): [Cây tổ chức] (trên) + [Danh sách + filter] (dưới), cuộn độc lập.
|
||||
// CỘT PHẢI (rộng): [Chi tiết 5 tab] — avatar header + Tổng quan / Thân nhân /
|
||||
// Trình độ / Kinh nghiệm / Hợp đồng.
|
||||
// Refine S66 (2026-06-16) theo anh góp ý từ eoffice LIVE (3 việc):
|
||||
// 1. layout 3-cột-ngang → 2-cột (tree+list xếp chồng cột trái · detail rộng phải).
|
||||
// 2+3. tô màu panel chi tiết: section header có accent (icon-chip nền màu nhạt +
|
||||
// heading đậm) + nhãn field brand-tint, dùng palette teal/violet/amberx/greenx.
|
||||
// GIỮ brand #1F7DC1 + Be Vietnam Pro · avatar header gradient brand giữ.
|
||||
// KHÔNG đổi logic — 100% chức năng giữ: 5 satellite inline CRUD (add/edit/delete +
|
||||
// mutex), search, filter, cây gốc "SOLUTION COMPANY" + TreeNode đệ quy, mọi TanStack
|
||||
// query/mutation key NGUYÊN (employees-list / employee-detail / departments-tree-hrm).
|
||||
// URL params: id (selected), q (search), status, deptId.
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@ -135,7 +140,7 @@ export function EmployeesListPage() {
|
||||
setSp(new URLSearchParams(), { replace: true })
|
||||
}
|
||||
|
||||
// Name of the currently filtered department (for the middle-panel subtitle).
|
||||
// Name of the currently filtered department (for the list-panel subtitle).
|
||||
const selectedDeptName = useMemo(() => {
|
||||
if (!deptFilter || !tree.data) return null
|
||||
let found: string | null = null
|
||||
@ -156,193 +161,197 @@ export function EmployeesListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-4 p-4 lg:grid-cols-[244px_352px_1fr]">
|
||||
{/* ===================== PANEL 1 — Org tree ===================== */}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col rounded-xl border border-slate-200 bg-white shadow-sm',
|
||||
// collapse to a slide-down on narrow screens
|
||||
'lg:!block',
|
||||
treeOpenMobile ? 'block' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||||
<span className="icon-chip" style={{ height: '2rem', width: '2rem' }}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-slate-900">Cây tổ chức</div>
|
||||
<div className="text-[11px] text-slate-400">Lọc theo phòng ban</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{tree.isLoading ? (
|
||||
<div className="px-2 py-6 text-center text-xs text-slate-400">Đang tải cây tổ chức…</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{/* Gốc công ty — bấm = tất cả NV; các phòng ban toả xuống dưới (cha→con) */}
|
||||
<li>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-lg pr-1.5 transition',
|
||||
!deptFilter ? 'bg-brand-50' : 'hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCompanyOpen(v => !v)}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded text-slate-400 hover:text-slate-700"
|
||||
aria-label={companyOpen ? 'Thu gọn' : 'Mở rộng'}
|
||||
aria-expanded={companyOpen}
|
||||
>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5 transition-transform', companyOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickDept(null)}
|
||||
// 2-column shell: left rail (tree + list stacked) | right detail (flex-1).
|
||||
// <lg: single column (tree → list → detail), tree collapses to a toggle.
|
||||
<div className="grid h-full grid-cols-1 gap-4 p-4 lg:grid-cols-[22rem_1fr] xl:grid-cols-[24rem_1fr]">
|
||||
{/* ===================== LEFT RAIL — tree (top) + list (bottom) ===================== */}
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
{/* ---- Org tree (cố định cao theo nội dung, tối đa ~44% rail) ---- */}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm',
|
||||
'lg:!flex lg:max-h-[44%] lg:shrink-0',
|
||||
treeOpenMobile ? 'flex' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||||
<span className="icon-chip" style={{ height: '2rem', width: '2rem' }}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-slate-900">Cây tổ chức</div>
|
||||
<div className="text-[11px] text-slate-400">Lọc theo phòng ban</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{tree.isLoading ? (
|
||||
<div className="px-2 py-6 text-center text-xs text-slate-400">Đang tải cây tổ chức…</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{/* Gốc công ty — bấm = tất cả NV; các phòng ban toả xuống dưới (cha→con) */}
|
||||
<li>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center justify-between gap-2 py-1.5 text-left text-sm transition',
|
||||
!deptFilter ? 'font-semibold text-brand-700' : 'text-slate-700',
|
||||
'group flex items-center gap-1 rounded-lg pr-1.5 transition',
|
||||
!deptFilter ? 'bg-brand-50' : 'hover:bg-slate-50',
|
||||
)}
|
||||
title="SOLUTION COMPANY"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-brand-500" />
|
||||
<span className="truncate font-semibold tracking-tight">SOLUTION COMPANY</span>
|
||||
</span>
|
||||
<CountBadge value={list.data?.total ?? 0} active={!deptFilter} />
|
||||
</button>
|
||||
</div>
|
||||
{companyOpen &&
|
||||
(!tree.data || tree.data.length === 0 ? (
|
||||
<div className="py-3 pl-9 text-xs text-slate-400">Chưa có phòng ban.</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{tree.data.map(n => (
|
||||
<TreeNode key={n.id} node={n} depth={1} selectedId={deptFilter} onPick={pickDept} />
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ===================== PANEL 2 — list + filter ===================== */}
|
||||
<section className="flex min-h-0 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold tracking-tight text-slate-900">Hồ sơ Nhân sự</h2>
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
{selectedDeptName ? `Phòng: ${selectedDeptName}` : 'Tất cả phòng ban'}
|
||||
{' · '}
|
||||
<span className="font-medium text-slate-700">{list.data?.total ?? 0}</span> hồ sơ
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* mobile-only tree toggle */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setTreeOpenMobile(v => !v)}
|
||||
>
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
Phòng ban
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate('/employees/new')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo mới
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* search + status filter strip */}
|
||||
<form onSubmit={applySearch} className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
value={localSearch}
|
||||
onChange={e => setLocalSearch(e.target.value)}
|
||||
placeholder="Tìm mã NV hoặc họ tên…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => setParam('status', e.target.value || null)}
|
||||
className="w-auto min-w-[9.5rem]"
|
||||
aria-label="Lọc theo trạng thái"
|
||||
>
|
||||
<option value="">Mọi trạng thái</option>
|
||||
<option value="1">Đang làm việc</option>
|
||||
<option value="2">Nghỉ phép</option>
|
||||
<option value="3">Đã nghỉ việc</option>
|
||||
</Select>
|
||||
<Button type="submit" size="sm">Tìm</Button>
|
||||
{(search || statusFilter || deptFilter) && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={resetFilters} title="Xoá bộ lọc">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* list table */}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
{list.isLoading ? (
|
||||
<div className="p-10 text-center text-sm text-slate-500">Đang tải…</div>
|
||||
) : !list.data || list.data.items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={UserCircle2}
|
||||
title="Không có hồ sơ phù hợp"
|
||||
description="Đổi bộ lọc, hoặc bấm 'Tạo mới' để thêm hồ sơ nhân viên."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500 shadow-[0_1px_0_theme(colors.slate.200)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Nhân viên</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Phòng ban</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.items.map(e => {
|
||||
const active = selectedId === e.id
|
||||
return (
|
||||
<tr
|
||||
key={e.id}
|
||||
onClick={() => setParam('id', e.id)}
|
||||
className={cn(
|
||||
'cursor-pointer border-b border-slate-100 transition last:border-0 hover:bg-slate-50',
|
||||
active && 'bg-brand-50 hover:bg-brand-50',
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCompanyOpen(v => !v)}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded text-slate-400 hover:text-slate-700"
|
||||
aria-label={companyOpen ? 'Thu gọn' : 'Mở rộng'}
|
||||
aria-expanded={companyOpen}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={e.fullName} size={32} dim={e.status === 3} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-900">{e.fullName ?? '—'}</div>
|
||||
<div className="font-mono text-[11px] text-slate-400">{e.employeeCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={e.status} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<ChevronRight className={cn('h-3.5 w-3.5 transition-transform', companyOpen && 'rotate-90')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickDept(null)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center justify-between gap-2 py-1.5 text-left text-sm transition',
|
||||
!deptFilter ? 'font-semibold text-brand-700' : 'text-slate-700',
|
||||
)}
|
||||
title="SOLUTION COMPANY"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-brand-500" />
|
||||
<span className="truncate font-semibold tracking-tight">SOLUTION COMPANY</span>
|
||||
</span>
|
||||
<CountBadge value={list.data?.total ?? 0} active={!deptFilter} />
|
||||
</button>
|
||||
</div>
|
||||
{companyOpen &&
|
||||
(!tree.data || tree.data.length === 0 ? (
|
||||
<div className="py-3 pl-9 text-xs text-slate-400">Chưa có phòng ban.</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{tree.data.map(n => (
|
||||
<TreeNode key={n.id} node={n} depth={1} selectedId={deptFilter} onPick={pickDept} />
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ===================== PANEL 3 — detail (5 tab) ===================== */}
|
||||
{/* ---- List + filter (chiếm phần còn lại của rail, cuộn riêng) ---- */}
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold tracking-tight text-slate-900">Hồ sơ Nhân sự</h2>
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
{selectedDeptName ? `Phòng: ${selectedDeptName}` : 'Tất cả phòng ban'}
|
||||
{' · '}
|
||||
<span className="font-medium text-slate-700">{list.data?.total ?? 0}</span> hồ sơ
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* mobile-only tree toggle */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setTreeOpenMobile(v => !v)}
|
||||
>
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
Phòng ban
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate('/employees/new')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo mới
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* search + status filter strip */}
|
||||
<form onSubmit={applySearch} className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
value={localSearch}
|
||||
onChange={e => setLocalSearch(e.target.value)}
|
||||
placeholder="Tìm mã NV hoặc họ tên…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={e => setParam('status', e.target.value || null)}
|
||||
className="w-auto min-w-[9.5rem]"
|
||||
aria-label="Lọc theo trạng thái"
|
||||
>
|
||||
<option value="">Mọi trạng thái</option>
|
||||
<option value="1">Đang làm việc</option>
|
||||
<option value="2">Nghỉ phép</option>
|
||||
<option value="3">Đã nghỉ việc</option>
|
||||
</Select>
|
||||
<Button type="submit" size="sm">Tìm</Button>
|
||||
{(search || statusFilter || deptFilter) && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={resetFilters} title="Xoá bộ lọc">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* list table */}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
{list.isLoading ? (
|
||||
<div className="p-10 text-center text-sm text-slate-500">Đang tải…</div>
|
||||
) : !list.data || list.data.items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={UserCircle2}
|
||||
title="Không có hồ sơ phù hợp"
|
||||
description="Đổi bộ lọc, hoặc bấm 'Tạo mới' để thêm hồ sơ nhân viên."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500 shadow-[0_1px_0_theme(colors.slate.200)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Nhân viên</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Phòng ban</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.items.map(e => {
|
||||
const active = selectedId === e.id
|
||||
return (
|
||||
<tr
|
||||
key={e.id}
|
||||
onClick={() => setParam('id', e.id)}
|
||||
className={cn(
|
||||
'cursor-pointer border-b border-slate-100 transition last:border-0 hover:bg-slate-50',
|
||||
active && 'bg-brand-50 hover:bg-brand-50',
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={e.fullName} size={32} dim={e.status === 3} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-900">{e.fullName ?? '—'}</div>
|
||||
<div className="font-mono text-[11px] text-slate-400">{e.employeeCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={e.status} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ===================== RIGHT — detail (5 tab) ===================== */}
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
|
||||
@ -485,6 +494,24 @@ function StatusBadge({ status }: { status: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===================== Section accent system (việc 2+3) =====================
|
||||
// Mỗi section nhận MỘT accent từ palette (teal/violet/amberx/greenx + brand). Accent
|
||||
// tô icon-chip (nền nhạt + chữ đậm), heading, và rail trái — tinh tế, KHÔNG loè loẹt.
|
||||
// Mọi cặp bg-{x}-50 + text-{x}-700 đã đạt WCAG-AA (xem index.css §ACCENT PALETTE).
|
||||
type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
|
||||
// NOTE: accent palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700
|
||||
// ONLY — no -800 (see index.css §ACCENT PALETTE). So headings use -700 across the
|
||||
// board (all clear WCAG-AA on white). Using a non-existent stop would silently emit
|
||||
// no class in Tailwind v4 → uncolored heading.
|
||||
const ACCENT: Record<Accent, { chipBg: string; chipFg: string; head: string; rail: string; labelText: string }> = {
|
||||
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-700', rail: 'before:bg-brand-500', labelText: 'text-brand-700' },
|
||||
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', rail: 'before:bg-teal-500', labelText: 'text-teal-700' },
|
||||
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', rail: 'before:bg-violet-500', labelText: 'text-violet-700' },
|
||||
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', rail: 'before:bg-amberx-500', labelText: 'text-amberx-700' },
|
||||
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', rail: 'before:bg-greenx-500', labelText: 'text-greenx-700' },
|
||||
}
|
||||
|
||||
// ===================== Detail with 5 tabs + satellite CRUD =====================
|
||||
|
||||
const TABS = [
|
||||
@ -700,6 +727,7 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
<Card
|
||||
title="Quan hệ gia đình"
|
||||
icon={Users}
|
||||
accent="violet"
|
||||
count={detail.familyRelations.length}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}>
|
||||
@ -751,6 +779,7 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
<Card
|
||||
title="Quá trình đào tạo"
|
||||
icon={GraduationCap}
|
||||
accent="teal"
|
||||
count={detail.educations.length}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}>
|
||||
@ -808,6 +837,7 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
<Card
|
||||
title="Kỹ năng"
|
||||
icon={ShieldCheck}
|
||||
accent="greenx"
|
||||
count={detail.skills.length}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}>
|
||||
@ -872,6 +902,7 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
<Card
|
||||
title="Quá trình công tác"
|
||||
icon={Briefcase}
|
||||
accent="amberx"
|
||||
count={detail.workHistories.length}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}>
|
||||
@ -928,6 +959,7 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
<Card
|
||||
title="Hồ sơ & hợp đồng giấy tờ"
|
||||
icon={FileText}
|
||||
accent="brand"
|
||||
count={detail.documents.length}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}>
|
||||
@ -996,114 +1028,115 @@ function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDe
|
||||
}
|
||||
|
||||
// ===================== Overview tab (2-column layout) =====================
|
||||
// Mỗi card mang một accent từ palette → section header có màu rõ, dễ scan, tinh tế.
|
||||
|
||||
function OverviewTab({ detail }: { detail: EmployeeDetail }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{/* LEFT column */}
|
||||
<div className="space-y-4">
|
||||
<Card title="Thông tin chung" icon={IdCard}>
|
||||
<Card title="Thông tin chung" icon={IdCard} accent="brand">
|
||||
<Grid2>
|
||||
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} icon={CalendarDays} />
|
||||
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} />
|
||||
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} />
|
||||
<Field label="Dân tộc" value={detail.ethnicity} />
|
||||
<Field label="Tôn giáo" value={detail.religion} />
|
||||
<Field label="Quốc tịch" value={detail.nationality} />
|
||||
<Field label="Nơi sinh" value={detail.birthPlace} />
|
||||
<Field label="Quê quán" value={detail.hometown} />
|
||||
<Field label="SĐT" value={detail.phone} icon={Phone} />
|
||||
<Field label="SĐT nội bộ" value={detail.internalPhone} />
|
||||
<Field label="Email" value={detail.email} icon={Mail} />
|
||||
<Field label="Email cá nhân" value={detail.personalEmail} />
|
||||
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} icon={CalendarDays} accent="brand" />
|
||||
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} accent="brand" />
|
||||
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} accent="brand" />
|
||||
<Field label="Dân tộc" value={detail.ethnicity} accent="brand" />
|
||||
<Field label="Tôn giáo" value={detail.religion} accent="brand" />
|
||||
<Field label="Quốc tịch" value={detail.nationality} accent="brand" />
|
||||
<Field label="Nơi sinh" value={detail.birthPlace} accent="brand" />
|
||||
<Field label="Quê quán" value={detail.hometown} accent="brand" />
|
||||
<Field label="SĐT" value={detail.phone} icon={Phone} accent="brand" />
|
||||
<Field label="SĐT nội bộ" value={detail.internalPhone} accent="brand" />
|
||||
<Field label="Email" value={detail.email} icon={Mail} accent="brand" />
|
||||
<Field label="Email cá nhân" value={detail.personalEmail} accent="brand" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Sức khoẻ" icon={HeartPulse}>
|
||||
<Card title="Sức khoẻ" icon={HeartPulse} accent="greenx">
|
||||
<Grid2>
|
||||
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} />
|
||||
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} />
|
||||
<Field label="Nhóm máu" value={detail.bloodType} />
|
||||
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} />
|
||||
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} accent="greenx" />
|
||||
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} accent="greenx" />
|
||||
<Field label="Nhóm máu" value={detail.bloodType} accent="greenx" />
|
||||
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} accent="greenx" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Liên hệ & địa chỉ" icon={MapPin}>
|
||||
<Card title="Liên hệ & địa chỉ" icon={MapPin} accent="teal">
|
||||
<Grid2>
|
||||
<Field label="Thường trú" value={detail.permanentAddressText} />
|
||||
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} />
|
||||
<Field label="Tạm trú" value={detail.temporaryAddressText} />
|
||||
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} />
|
||||
<Field label="Liên hệ khẩn cấp" value={detail.emergencyContactName} />
|
||||
<Field label="SĐT khẩn cấp" value={detail.emergencyContactPhone} />
|
||||
<Field label="Địa chỉ khẩn cấp" value={detail.emergencyContactAddress} full />
|
||||
<Field label="Thường trú" value={detail.permanentAddressText} accent="teal" />
|
||||
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} accent="teal" />
|
||||
<Field label="Tạm trú" value={detail.temporaryAddressText} accent="teal" />
|
||||
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} accent="teal" />
|
||||
<Field label="Liên hệ khẩn cấp" value={detail.emergencyContactName} accent="teal" />
|
||||
<Field label="SĐT khẩn cấp" value={detail.emergencyContactPhone} accent="teal" />
|
||||
<Field label="Địa chỉ khẩn cấp" value={detail.emergencyContactAddress} full accent="teal" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Giấy tờ tuỳ thân" icon={IdCard}>
|
||||
<Card title="Giấy tờ tuỳ thân" icon={IdCard} accent="violet">
|
||||
<Grid2>
|
||||
<Field label="CMND/CCCD" value={detail.idCardNumber} mono />
|
||||
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} />
|
||||
<Field label="Nơi cấp" value={detail.idCardIssuePlace} full />
|
||||
<Field label="Hộ chiếu" value={detail.passportNumber} mono />
|
||||
<Field label="MST cá nhân" value={detail.taxCode} mono />
|
||||
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
|
||||
<Field label="CMND/CCCD" value={detail.idCardNumber} mono accent="violet" />
|
||||
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} accent="violet" />
|
||||
<Field label="Nơi cấp" value={detail.idCardIssuePlace} full accent="violet" />
|
||||
<Field label="Hộ chiếu" value={detail.passportNumber} mono accent="violet" />
|
||||
<Field label="MST cá nhân" value={detail.taxCode} mono accent="violet" />
|
||||
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono accent="violet" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* RIGHT column */}
|
||||
<div className="space-y-4">
|
||||
<Card title="Công việc & chế độ" icon={Briefcase}>
|
||||
<Card title="Công việc & chế độ" icon={Briefcase} accent="amberx">
|
||||
<Grid2>
|
||||
<Field label="Mã NV" value={detail.employeeCode} mono />
|
||||
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} />
|
||||
<Field label="Phòng ban" value={detail.departmentName} />
|
||||
<Field label="Vị trí công tác" value={detail.workLocation} />
|
||||
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} icon={CalendarDays} />
|
||||
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} />
|
||||
<Field label="Mã chấm công" value={detail.timekeepingCode} mono />
|
||||
<Field label="Trình độ chuyên môn" value={detail.qualification} />
|
||||
<Field label="Mã NV" value={detail.employeeCode} mono accent="amberx" />
|
||||
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} accent="amberx" />
|
||||
<Field label="Phòng ban" value={detail.departmentName} accent="amberx" />
|
||||
<Field label="Vị trí công tác" value={detail.workLocation} accent="amberx" />
|
||||
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} icon={CalendarDays} accent="amberx" />
|
||||
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} accent="amberx" />
|
||||
<Field label="Mã chấm công" value={detail.timekeepingCode} mono accent="amberx" />
|
||||
<Field label="Trình độ chuyên môn" value={detail.qualification} accent="amberx" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Lương & bảo hiểm" icon={Wallet}>
|
||||
<Card title="Lương & bảo hiểm" icon={Wallet} accent="greenx">
|
||||
<Grid2>
|
||||
<Field label="Lương cơ bản" value={fmtMoney(detail.baseSalary)} />
|
||||
<Field label="Tổng lương" value={fmtMoney(detail.totalSalary)} />
|
||||
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} />
|
||||
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
|
||||
<Field label="Lương cơ bản" value={fmtMoney(detail.baseSalary)} accent="greenx" />
|
||||
<Field label="Tổng lương" value={fmtMoney(detail.totalSalary)} accent="greenx" />
|
||||
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} accent="greenx" />
|
||||
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono accent="greenx" />
|
||||
</Grid2>
|
||||
<SubLabel>Ngày phép</SubLabel>
|
||||
<SubLabel accent="greenx">Ngày phép</SubLabel>
|
||||
<Grid2>
|
||||
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} />
|
||||
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} />
|
||||
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} />
|
||||
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} />
|
||||
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} accent="greenx" />
|
||||
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} accent="greenx" />
|
||||
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} accent="greenx" />
|
||||
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} accent="greenx" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Ngân hàng" icon={Landmark}>
|
||||
<Card title="Ngân hàng" icon={Landmark} accent="teal">
|
||||
<Grid2>
|
||||
<Field label="Số tài khoản" value={detail.bankAccount} mono />
|
||||
<Field label="Ngân hàng" value={detail.bankName} />
|
||||
<Field label="Chi nhánh" value={detail.bankBranch} full />
|
||||
<Field label="Số tài khoản" value={detail.bankAccount} mono accent="teal" />
|
||||
<Field label="Ngân hàng" value={detail.bankName} accent="teal" />
|
||||
<Field label="Chi nhánh" value={detail.bankBranch} full accent="teal" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
<Card title="Đoàn thể" icon={ShieldCheck}>
|
||||
<Card title="Đoàn thể" icon={ShieldCheck} accent="violet">
|
||||
<Grid2>
|
||||
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} />
|
||||
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} />
|
||||
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} />
|
||||
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} />
|
||||
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} />
|
||||
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} />
|
||||
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} accent="violet" />
|
||||
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} accent="violet" />
|
||||
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} accent="violet" />
|
||||
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} accent="violet" />
|
||||
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} accent="violet" />
|
||||
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} accent="violet" />
|
||||
</Grid2>
|
||||
</Card>
|
||||
|
||||
{detail.notes && (
|
||||
<Card title="Ghi chú" icon={FileText}>
|
||||
<Card title="Ghi chú" icon={FileText} accent="brand">
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-700">{detail.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
@ -1495,55 +1528,69 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () =>
|
||||
}
|
||||
|
||||
// Card = section container for the tab body (replaces the old <details> Section).
|
||||
function Card({ title, icon: Icon, count, action, children }: {
|
||||
// việc 2+3: nhận accent → icon-chip nền màu nhạt + chữ đậm, heading màu đậm, rail trái.
|
||||
function Card({ title, icon: Icon, count, action, accent = 'brand', children }: {
|
||||
title: string
|
||||
icon: typeof IdCard
|
||||
count?: number
|
||||
action?: React.ReactNode
|
||||
accent?: Accent
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const a = ACCENT[accent]
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5">
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm',
|
||||
// colored left rail (pseudo-element clips to the rounded corner cleanly)
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", a.rail,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="icon-chip" style={{ height: '1.75rem', width: '1.75rem' }}>
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-slate-900">{title}</h3>
|
||||
<h3 className={cn('text-sm font-semibold tracking-tight', a.head)}>{title}</h3>
|
||||
{count != null && count > 0 && (
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
<div className="p-4 pl-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SubLabel({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-1 mt-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{children}</div>
|
||||
function SubLabel({ children, accent = 'brand' }: { children: React.ReactNode; accent?: Accent }) {
|
||||
return <div className={cn('mb-1 mt-3 text-xs font-semibold uppercase tracking-wide', ACCENT[accent].labelText)}>{children}</div>
|
||||
}
|
||||
|
||||
function Grid2({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 gap-x-4 gap-y-2.5 sm:grid-cols-2">{children}</div>
|
||||
}
|
||||
|
||||
function Field({ label, value, mono, icon: Icon, full }: {
|
||||
// Field — nhãn uppercase brand/accent-tint (việc 3), value đậm rõ. Empty = dấu —.
|
||||
function Field({ label, value, mono, icon: Icon, full, accent = 'brand' }: {
|
||||
label: string
|
||||
value: string | number | null
|
||||
mono?: boolean
|
||||
icon?: typeof Phone
|
||||
full?: boolean
|
||||
accent?: Accent
|
||||
}) {
|
||||
const empty = value == null || value === ''
|
||||
return (
|
||||
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
|
||||
<div className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||||
<div className={cn('flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide', ACCENT[accent].labelText)}>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
{label}
|
||||
</div>
|
||||
<div className={cn('mt-0.5 break-words', empty ? 'text-slate-300' : 'text-slate-800', mono && !empty && 'font-mono text-xs')}>
|
||||
<div className={cn('mt-0.5 break-words', empty ? 'text-slate-300' : 'font-medium text-slate-900', mono && !empty && 'font-mono text-xs')}>
|
||||
{empty ? '—' : value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user