From 292d64d8433df70d775688a837c68b35f1365048 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 16 Jun 2026 13:51:20 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin:=20mirror=20H=E1=BB=93=20s?= =?UTF-8?q?=C6=A1=20Nh=C3=A2n=20s=E1=BB=B1=20master-detail=20t=E1=BB=AB=20?= =?UTF-8?q?fe-user=20(page=20SHA256=20identical=20+=20accent=20tokens=20in?= =?UTF-8?q?dex.css)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- fe-admin/src/index.css | 81 + fe-admin/src/pages/hrm/EmployeesListPage.tsx | 1488 +++++++++++------- 2 files changed, 1026 insertions(+), 543 deletions(-) diff --git a/fe-admin/src/index.css b/fe-admin/src/index.css index b2b5508..f688d0e 100644 --- a/fe-admin/src/index.css +++ b/fe-admin/src/index.css @@ -19,6 +19,36 @@ --color-accent-500: #dc2626; --color-accent-600: #b91c1c; + /* ─────────────────────────────────────────────────────────────────── + ACCENT PALETTE (mirror fe-user — anh chốt 2026-06-16 "nâng màu, giữ nền + xanh brand"). Dùng cho Hồ sơ NS master-detail + KPI/badge đa-tông. Mỗi + palette ship 50/100/500/600/700 → bg-{x}-50 chip + text-{x}-700 đạt WCAG-AA. + ─────────────────────────────────────────────────────────────────── */ + /* teal — info / secondary metric */ + --color-teal-50: #e9faf9; + --color-teal-100: #ccf3f1; + --color-teal-500: #0ea5a4; + --color-teal-600: #0c8e8d; + --color-teal-700: #0a7170; + /* amber/cam — warning / pending */ + --color-amberx-50: #fef6e7; + --color-amberx-100: #fce8c2; + --color-amberx-500: #f59e0b; + --color-amberx-600: #d98306; + --color-amberx-700: #b16708; + /* violet/tím — neutral-highlight / value */ + --color-violet-50: #f1effe; + --color-violet-100: #e0dcfc; + --color-violet-500: #7c6ff0; + --color-violet-600: #6354e4; + --color-violet-700: #5042c4; + /* green/lục — success / done */ + --color-greenx-50: #e8f8ee; + --color-greenx-100: #c7eed5; + --color-greenx-500: #16a34a; + --color-greenx-600: #128a3f; + --color-greenx-700: #0f7034; + --font-sans: "Be Vietnam Pro", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -59,6 +89,57 @@ h1, h2, h3, h4 { color: #64748b; /* slate-500 — WCAG-AA on white (4.6:1) */ } +/* ───────────────────────────────────────────────────────────────────────── + REUSABLE VISUAL UTILITIES (mirror fe-user — Hồ sơ NS master-detail dùng + .icon-chip + .app-gradient-brand; .card-accent/.stat-value cho KPI sau). + ───────────────────────────────────────────────────────────────────────── */ + +/* Brand gradient surface — page/section headers, hero strips (avatar header). */ +.app-gradient-brand { + background-image: linear-gradient(120deg, var(--color-brand-600) 0%, var(--color-brand-700) 55%, var(--color-brand-800) 100%); +} + +/* KPI / metric card — colored LEFT border + soft lift. Recolour via --accent. */ +.card-accent { + position: relative; + border-radius: 0.75rem; + border: 1px solid #e9eef4; + background: #fff; + border-left: 3px solid var(--accent, var(--color-brand-500)); + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04), 0 1px 3px rgb(15 23 42 / 0.06); + transition: box-shadow .18s ease, transform .18s ease; +} +.card-accent:hover { + box-shadow: 0 4px 10px rgb(15 23 42 / 0.08), 0 2px 4px rgb(15 23 42 / 0.06); + transform: translateY(-1px); +} + +/* Icon chip — soft tinted square behind a lucide icon. Recolour via --chip-bg / --chip-fg. */ +.icon-chip { + display: inline-flex; + align-items: center; + justify-content: center; + height: 2.25rem; + width: 2.25rem; + border-radius: 0.625rem; + background: var(--chip-bg, var(--color-brand-50)); + color: var(--chip-fg, var(--color-brand-600)); +} + +/* Big stat number — tabular, tight, dark. */ +.stat-value { + font-variant-numeric: tabular-nums; + font-weight: 700; + letter-spacing: -0.02em; + color: #0b1220; + line-height: 1.1; +} + +@media (prefers-reduced-motion: reduce) { + .card-accent { transition: none; } + .card-accent:hover { transform: none; } +} + /* Tabular numbers in tables + stat cards for better alignment */ table, .tabular-nums { font-variant-numeric: tabular-nums; diff --git a/fe-admin/src/pages/hrm/EmployeesListPage.tsx b/fe-admin/src/pages/hrm/EmployeesListPage.tsx index b2208b5..d445f2a 100644 --- a/fe-admin/src/pages/hrm/EmployeesListPage.tsx +++ b/fe-admin/src/pages/hrm/EmployeesListPage.tsx @@ -1,12 +1,25 @@ -// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail. -// Phase 10.1 G-H1 Phase 1.5 (S35 Plan B-WrapPlus1) — inline forms 5 satellite cookie-cutter. -// Pattern 12-ter × 16-bis 6th reinforcement (S35). 5 inline forms × 2 app mirror SHA256 IDENTICAL. -// URL params: id (selected), q (search), status, deptId -import { useState } from 'react' +// 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' import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'sonner' -import { UserCircle2, Search, Plus, X, Pencil, Trash2 } from 'lucide-react' +import { + UserCircle2, Search, Plus, X, Pencil, Trash2, ChevronRight, Building2, + Users, IdCard, GraduationCap, Briefcase, FileText, Phone, Mail, MapPin, + CalendarDays, Wallet, Landmark, HeartPulse, ShieldCheck, +} from 'lucide-react' import { Input } from '@/components/ui/Input' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' @@ -15,7 +28,7 @@ import { EmptyState } from '@/components/EmptyState' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' -import type { Paged, Department } from '@/types/master' +import type { Paged } from '@/types/master' import { EmployeeStatusColor, EmployeeStatusLabel, @@ -28,6 +41,7 @@ import { FamilyRelationKindLabel, SkillKind, SkillKindLabel, + EmployeeDocumentType, EmployeeDocumentTypeLabel, type EmployeeListItem, type EmployeeDetail, @@ -45,6 +59,18 @@ import { const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—') +const fmtMoney = (n: number | null) => (n != null ? n.toLocaleString('vi-VN') + ' đ' : null) + +// Org-tree node mirror BE DepartmentTreeNodeDto (DepartmentFeatures.cs). +type DepartmentTreeNode = { + id: string + code: string + name: string + parentId: string | null + directEmployeeCount: number + totalEmployeeCount: number + children: DepartmentTreeNode[] +} export function EmployeesListPage() { const navigate = useNavigate() @@ -56,11 +82,14 @@ export function EmployeesListPage() { const selectedId = sp.get('id') const [localSearch, setLocalSearch] = useState(search) + const [treeOpenMobile, setTreeOpenMobile] = useState(false) + const [companyOpen, setCompanyOpen] = useState(true) // gốc công ty mở mặc định - const departments = useQuery({ - queryKey: ['departments-all-hrm'], - queryFn: async () => - (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + // Org tree (consume /departments/tree). Class-level [Authorize] only → any + // authenticated user. Counts come pre-rolled-up from BE (TotalEmployeeCount). + const tree = useQuery({ + queryKey: ['departments-tree-hrm'], + queryFn: async () => (await api.get('/departments/tree')).data, }) const list = useQuery({ @@ -111,149 +140,395 @@ export function EmployeesListPage() { setSp(new URLSearchParams(), { replace: true }) } - return ( -
- {/* ========== LEFT PANEL: filter ========== */} - - - {/* ========== RIGHT PANEL: list table + selected detail ========== */} -
-
-

Danh sách nhân viên

- -
- -
- {list.isLoading ? ( -
Đang tải...
- ) : !list.data || list.data.items.length === 0 ? ( - - ) : ( - - - - - - - - - - - - {list.data.items.map(e => ( - setParam('id', e.id)} - className={cn( - 'cursor-pointer border-b border-slate-100 transition hover:bg-slate-50', - selectedId === e.id && 'bg-brand-50 hover:bg-brand-50', - )} - > - - - - - - - ))} - -
Mã NVHọ tênPhòng banTrạng tháiSĐT
{e.employeeCode}{e.fullName ?? '—'}{e.departmentName ?? '—'} - - {EmployeeStatusLabel[e.status]} - - {e.phone ?? '—'}
- )} -
- - {/* ========== Selected detail (6 collapsible section inline) ========== */} - {selectedId && ( -
- {detail.isLoading ? ( -
Đang tải chi tiết...
- ) : !detail.data ? ( -
Không tìm thấy hồ sơ.
+ {/* list table */} +
+ {list.isLoading ? ( +
Đang tải…
+ ) : !list.data || list.data.items.length === 0 ? ( + ) : ( - del.mutate(detail.data!.id)} /> + + + + + + + + + + {list.data.items.map(e => { + const active = selectedId === e.id + return ( + 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', + )} + > + + + + + ) + })} + +
Nhân viênPhòng banTrạng thái
+
+ +
+
{e.fullName ?? '—'}
+
{e.employeeCode}
+
+
+
{e.departmentName ?? '—'} + +
)}
+
+
+ + {/* ===================== RIGHT — detail (5 tab) ===================== */} +
+ {!selectedId ? ( +
+ + + +

Chọn một nhân viên

+

+ Bấm vào một dòng ở danh sách để xem hồ sơ chi tiết: tổng quan, thân nhân, trình độ, kinh nghiệm và hợp đồng. +

+
+ ) : detail.isLoading ? ( +
Đang tải chi tiết…
+ ) : !detail.data ? ( +
Không tìm thấy hồ sơ.
+ ) : ( + del.mutate(detail.data!.id)} /> )}
) } -// ========== Inline 6-section detail with CRUD for satellites ========== +// ===================== Org tree node (recursive) ===================== -function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) { +function TreeNode({ node, depth, selectedId, onPick }: { + node: DepartmentTreeNode + depth: number + selectedId: string + onPick: (id: string) => void +}) { + const hasChildren = node.children.length > 0 + const [open, setOpen] = useState(depth < 1) // top level expanded by default + const active = selectedId === node.id + + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + +
    + {hasChildren && open && ( +
      + {node.children.map(c => ( + + ))} +
    + )} +
  • + ) +} + +function CountBadge({ value, active }: { value: number; active: boolean }) { + return ( + + {value} + + ) +} + +// ===================== Avatar + status badge ===================== + +const AVATAR_TONES = [ + 'from-brand-500 to-brand-700', + 'from-teal-500 to-teal-700', + 'from-violet-500 to-violet-700', + 'from-amberx-500 to-amberx-700', + 'from-greenx-500 to-greenx-700', +] as const + +function initials(name: string | null): string { + if (!name) return '?' + const parts = name.trim().split(/\s+/) + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +function toneFor(name: string | null): string { + const s = name ?? '' + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 + return AVATAR_TONES[h % AVATAR_TONES.length] +} + +function Avatar({ name, size, dim }: { name: string | null; size: number; dim?: boolean }) { + return ( + + {initials(name)} + + ) +} + +function StatusBadge({ status }: { status: number }) { + return ( + + {EmployeeStatusLabel[status]} + + ) +} + +// ===================== 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 = { + 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 = [ + { key: 'overview', label: 'Tổng quan', icon: IdCard }, + { key: 'family', label: 'Thân nhân', icon: Users }, + { key: 'education', label: 'Trình độ', icon: GraduationCap }, + { key: 'experience', label: 'Kinh nghiệm', icon: Briefcase }, + { key: 'contract', label: 'Hợp đồng', icon: FileText }, +] as const +type TabKey = typeof TABS[number]['key'] + +function EmployeeDetailTabs({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) { const qc = useQueryClient() const employeeId = detail.id + const [tab, setTab] = useState('overview') - // State for inline add/edit forms (5 satellite) + // State for inline add/edit forms (5 satellite) — UNCHANGED logic. const [addingWorkHistory, setAddingWorkHistory] = useState(false) const [editingWorkHistoryId, setEditingWorkHistoryId] = useState(null) const [addingEducation, setAddingEducation] = useState(false) @@ -362,413 +637,515 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onError: e => toast.error(getErrorMessage(e)), }) + // Contracts tab = documents of type LaborContract; the rest live under "Hợp đồng" + // alongside other paperwork (keeps original 6th section's full document CRUD). + const tabCount: Record = { + overview: null, + family: detail.familyRelations.length, + education: detail.educations.length + detail.skills.length, + experience: detail.workHistories.length, + contract: detail.documents.length, + } + return ( -
    - {/* Header bar */} -
    -
    -
    - {detail.photoUrl ? ( - {detail.fullName - ) : ( - - )} -
    -
    -

    {detail.fullName ?? '—'}

    -
    - {detail.employeeCode} - - - {EmployeeStatusLabel[detail.employeeStatus]} - - {detail.departmentName && ( - <> - - {detail.departmentName} - - )} +
    + {/* ===== Avatar header (brand gradient) ===== */} +
    +
    +
    + + {initials(detail.fullName)} + +
    +

    {detail.fullName ?? '—'}

    +
    + {detail.employeeCode} + {detail.departmentName && (<>{detail.departmentName})} + {detail.workLocation && (<>{detail.workLocation})} +
    +
    + + {EmployeeStatusLabel[detail.employeeStatus]} + +
    +
    -
    - {/* Section 1: Cơ bản */} -
    - - - - - - - - - - - - - - - - - - - - - - + {/* ===== Tab bar ===== */} + - - - - - - - - - - + {/* ===== Tab body (scroll) ===== */} +
    + {tab === 'overview' && } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {detail.notes && ( - -

    {detail.notes}

    -
    - )} -
    - - {/* Section 2: Công tác */} -
    { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}> - Thêm - - } - > - {addingWorkHistory && ( - createWorkHistory.mutate(p)} - onCancel={() => setAddingWorkHistory(false)} - isPending={createWorkHistory.isPending} - /> - )} - {detail.workHistories.length === 0 && !addingWorkHistory ? ( - - ) : ( -
    - {detail.workHistories.map(w => ( - editingWorkHistoryId === w.id ? ( - updateWorkHistory.mutate({ satId: w.id, payload: p })} - onCancel={() => setEditingWorkHistoryId(null)} - isPending={updateWorkHistory.isPending} - /> - ) : ( -
    -
    -
    {w.companyName}
    -
    - {fmtDate(w.fromDate)} → {fmtDate(w.toDate)} + {tab === 'family' && ( + { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}> + Thêm thân nhân + + } + > + {addingFamilyRelation && ( + createFamilyRelation.mutate(p)} + onCancel={() => setAddingFamilyRelation(false)} + isPending={createFamilyRelation.isPending} + /> + )} + {detail.familyRelations.length === 0 && !addingFamilyRelation ? ( + + ) : ( +
    + {detail.familyRelations.map(f => ( + editingFamilyRelationId === f.id ? ( + updateFamilyRelation.mutate({ satId: f.id, payload: p })} + onCancel={() => setEditingFamilyRelationId(null)} + isPending={updateFamilyRelation.isPending} + /> + ) : ( +
    +
    Họ tên: {f.fullName}
    +
    Quan hệ: {FamilyRelationKindLabel[f.relationship]}
    +
    Năm sinh: {f.birthYear ?? '—'}
    +
    Nghề: {f.occupation ?? '—'}
    +
    SĐT: {f.phone ?? '—'}
    + { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }} + onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }} + />
    -
    - {w.jobTitle &&
    Chức vụ: {w.jobTitle}
    } - {w.industry &&
    Ngành: {w.industry}
    } - {w.companyAddress &&
    Địa chỉ: {w.companyAddress}
    } - {w.jobDescription &&
    {w.jobDescription}
    } - {w.resignReason &&
    Lý do nghỉ: {w.resignReason}
    } - { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }} - onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }} - /> -
    - ) - ))} -
    + ) + ))} +
    + )} + )} -
    - {/* Section 3: Đào tạo */} -
    { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}> - Thêm - - } - > - {addingEducation && ( - createEducation.mutate(p)} - onCancel={() => setAddingEducation(false)} - isPending={createEducation.isPending} - /> - )} - {detail.educations.length === 0 && !addingEducation ? ( - - ) : ( -
    - {detail.educations.map(ed => ( - editingEducationId === ed.id ? ( + {tab === 'education' && ( +
    + { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}> + Thêm + + } + > + {addingEducation && ( updateEducation.mutate({ satId: ed.id, payload: p })} - onCancel={() => setEditingEducationId(null)} - isPending={updateEducation.isPending} + onSave={p => createEducation.mutate(p)} + onCancel={() => setAddingEducation(false)} + isPending={createEducation.isPending} /> + )} + {detail.educations.length === 0 && !addingEducation ? ( + ) : ( -
    -
    -
    {ed.schoolName}
    -
    - {fmtDate(ed.fromDate)} → {fmtDate(ed.toDate)} -
    -
    -
    - {ed.major && Chuyên ngành: {ed.major}} - {ed.degreeLevel != null && Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}} - {ed.educationMode != null && Hình thức: {EducationModeLabel[ed.educationMode]}} - {ed.gradeLevel != null && Xếp loại: {GradeLevelLabel[ed.gradeLevel]}} -
    - {ed.certificateIssueDate &&
    Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}
    } - {ed.notes &&
    {ed.notes}
    } - { setEditingEducationId(ed.id); setAddingEducation(false) }} - onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }} - /> -
    - ) - ))} -
    - )} -
    - - {/* Section 4: Thân nhân */} -
    { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}> - Thêm - - } - > - {addingFamilyRelation && ( - createFamilyRelation.mutate(p)} - onCancel={() => setAddingFamilyRelation(false)} - isPending={createFamilyRelation.isPending} - /> - )} - {detail.familyRelations.length === 0 && !addingFamilyRelation ? ( - - ) : ( -
    - {detail.familyRelations.map(f => ( - editingFamilyRelationId === f.id ? ( - updateFamilyRelation.mutate({ satId: f.id, payload: p })} - onCancel={() => setEditingFamilyRelationId(null)} - isPending={updateFamilyRelation.isPending} - /> - ) : ( -
    -
    Họ tên: {f.fullName}
    -
    Quan hệ: {FamilyRelationKindLabel[f.relationship]}
    -
    Năm sinh: {f.birthYear ?? '—'}
    -
    Nghề: {f.occupation ?? '—'}
    -
    SĐT: {f.phone ?? '—'}
    - { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }} - onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }} - /> -
    - ) - ))} -
    - )} -
    - - {/* Section 5: Kỹ năng */} -
    { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}> - Thêm - - } - > - {addingSkill && ( - createSkill.mutate(p)} - onCancel={() => setAddingSkill(false)} - isPending={createSkill.isPending} - /> - )} - {detail.skills.length === 0 && !addingSkill ? ( - - ) : ( -
    - {[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => { - const group = detail.skills.filter(s => s.kind === kind) - if (group.length === 0) return null - return ( -
    -
    - {SkillKindLabel[kind]} -
    -
      - {group.map(s => ( - editingSkillId === s.id ? ( - updateSkill.mutate({ satId: s.id, payload: p })} - onCancel={() => setEditingSkillId(null)} - isPending={updateSkill.isPending} - /> - ) : ( -
    • -
      - {s.name} - {s.level && {s.level}} +
      + {detail.educations.map(ed => ( + editingEducationId === ed.id ? ( + updateEducation.mutate({ satId: ed.id, payload: p })} + onCancel={() => setEditingEducationId(null)} + isPending={updateEducation.isPending} + /> + ) : ( +
      +
      +
      {ed.schoolName}
      +
      + {fmtDate(ed.fromDate)} → {fmtDate(ed.toDate)}
      - {s.languageId &&
      Mã: {s.languageId}
      } - { setEditingSkillId(s.id); setAddingSkill(false) }} - onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }} - /> -
    • - ) - ))} -
    +
    +
    + {ed.major && Chuyên ngành: {ed.major}} + {ed.degreeLevel != null && Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}} + {ed.educationMode != null && Hình thức: {EducationModeLabel[ed.educationMode]}} + {ed.gradeLevel != null && Xếp loại: {GradeLevelLabel[ed.gradeLevel]}} +
    + {ed.certificateIssueDate &&
    Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}
    } + {ed.notes &&
    {ed.notes}
    } + { setEditingEducationId(ed.id); setAddingEducation(false) }} + onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }} + /> +
    + ) + ))}
    - ) - })} -
    - )} - + )} + - {/* Section 6: Hồ sơ */} -
    { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}> - Thêm - - } - > - {addingDocument && ( - createDocument.mutate(p)} - onCancel={() => setAddingDocument(false)} - isPending={createDocument.isPending} - /> - )} - {detail.documents.length === 0 && !addingDocument ? ( - - ) : ( -
    - {detail.documents.map(doc => ( - editingDocumentId === doc.id ? ( - updateDocument.mutate({ satId: doc.id, payload: p })} - onCancel={() => setEditingDocumentId(null)} - isPending={updateDocument.isPending} + { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}> + Thêm + + } + > + {addingSkill && ( + createSkill.mutate(p)} + onCancel={() => setAddingSkill(false)} + isPending={createSkill.isPending} /> + )} + {detail.skills.length === 0 && !addingSkill ? ( + ) : ( -
    -
    Loại: {EmployeeDocumentTypeLabel[doc.documentType]}
    -
    - Tên file: - {doc.fileName} -
    -
    Ngày cấp: {fmtDate(doc.issueDate)}
    -
    Hết hạn: {fmtDate(doc.expiryDate)}
    - { setEditingDocumentId(doc.id); setAddingDocument(false) }} - onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }} - /> +
    + {[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => { + const group = detail.skills.filter(s => s.kind === kind) + if (group.length === 0) return null + return ( +
    +
    + {SkillKindLabel[kind]} +
    +
      + {group.map(s => ( + editingSkillId === s.id ? ( + updateSkill.mutate({ satId: s.id, payload: p })} + onCancel={() => setEditingSkillId(null)} + isPending={updateSkill.isPending} + /> + ) : ( +
    • +
      + {s.name} + {s.level && {s.level}} +
      + {s.languageId &&
      Mã: {s.languageId}
      } + { setEditingSkillId(s.id); setAddingSkill(false) }} + onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }} + /> +
    • + ) + ))} +
    +
    + ) + })}
    - ) - ))} + )} +
    )} -
    -
    - Tạo: {fmtDateTime(detail.createdAt)} - {detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}} -
    + {tab === 'experience' && ( + { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}> + Thêm kinh nghiệm + + } + > + {addingWorkHistory && ( + createWorkHistory.mutate(p)} + onCancel={() => setAddingWorkHistory(false)} + isPending={createWorkHistory.isPending} + /> + )} + {detail.workHistories.length === 0 && !addingWorkHistory ? ( + + ) : ( +
    + {detail.workHistories.map(w => ( + editingWorkHistoryId === w.id ? ( + updateWorkHistory.mutate({ satId: w.id, payload: p })} + onCancel={() => setEditingWorkHistoryId(null)} + isPending={updateWorkHistory.isPending} + /> + ) : ( +
    +
    +
    {w.companyName}
    +
    + {fmtDate(w.fromDate)} → {fmtDate(w.toDate)} +
    +
    + {w.jobTitle &&
    Chức vụ: {w.jobTitle}
    } + {w.industry &&
    Ngành: {w.industry}
    } + {w.companyAddress &&
    Địa chỉ: {w.companyAddress}
    } + {w.jobDescription &&
    {w.jobDescription}
    } + {w.resignReason &&
    Lý do nghỉ: {w.resignReason}
    } + { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }} + onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }} + /> +
    + ) + ))} +
    + )} +
    + )} + + {tab === 'contract' && ( + { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}> + Thêm hồ sơ + + } + > + {addingDocument && ( + createDocument.mutate(p)} + onCancel={() => setAddingDocument(false)} + isPending={createDocument.isPending} + /> + )} + {detail.documents.length === 0 && !addingDocument ? ( + + ) : ( +
    + {detail.documents.map(doc => ( + editingDocumentId === doc.id ? ( + updateDocument.mutate({ satId: doc.id, payload: p })} + onCancel={() => setEditingDocumentId(null)} + isPending={updateDocument.isPending} + /> + ) : ( +
    +
    + Loại: + + {EmployeeDocumentTypeLabel[doc.documentType]} + +
    +
    + Tên file: + {doc.fileName} +
    +
    Ngày cấp: {fmtDate(doc.issueDate)}
    +
    Hết hạn: {fmtDate(doc.expiryDate)}
    + { setEditingDocumentId(doc.id); setAddingDocument(false) }} + onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }} + /> +
    + ) + ))} +
    + )} +
    + )} + +
    + Tạo: {fmtDateTime(detail.createdAt)} + {detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}} +
    +
    ) } -// ========== Inline forms for 5 satellite (cookie-cutter pattern 12-ter) ========== +// ===================== 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 ( +
    + {/* LEFT column */} +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + {/* RIGHT column */} +
    + + + + + + + + + + + + + + + + + + + + + Ngày phép + + + + + + + + + + + + + + + + + + + + + + + + + + + + {detail.notes && ( + +

    {detail.notes}

    +
    + )} +
    +
    + ) +} + +// ===================== Inline forms for 5 satellite (cookie-cutter, UNCHANGED) ===================== const nullable = (s: string) => s.trim() || null const nullableNumber = (s: string): number | null => { @@ -808,7 +1185,7 @@ function WorkHistoryForm({ initial, onSave, onCancel, isPending }: { } return ( -
    + setForm({ ...form, companyName: e.target.value })} required maxLength={200} /> @@ -874,7 +1251,7 @@ function EducationForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, schoolName: e.target.value })} required maxLength={200} /> @@ -953,7 +1330,7 @@ function FamilyRelationForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, fullName: e.target.value })} required maxLength={200} /> @@ -1010,7 +1387,7 @@ function SkillForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, documentType: e.target.value })} required> @@ -1105,7 +1482,7 @@ function DocumentForm({ initial, onSave, onCancel, isPending }: { ) } -// ========== Form helpers ========== +// ===================== Form + layout helpers ===================== function FormField({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -1150,46 +1527,71 @@ function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => ) } -// ========== Layout helpers (read-only) ========== - -function Section({ title, children, defaultOpen, actions }: { title: string; children: React.ReactNode; defaultOpen?: boolean; actions?: React.ReactNode }) { +// Card = section container for the tab body (replaces the old
    Section). +// 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 ( -
    - - - - {title} - - {actions && ( - e.stopPropagation()}> - {actions} +
    +
    +
    + + - )} -
    -
    {children}
    -
    +

    {title}

    + {count != null && count > 0 && ( + {count} + )} +
    + {action} + +
    {children}
    + ) } -function SubBlock({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
    -
    {title}
    - {children} -
    - ) +function SubLabel({ children, accent = 'brand' }: { children: React.ReactNode; accent?: Accent }) { + return
    {children}
    } function Grid2({ children }: { children: React.ReactNode }) { - return
    {children}
    + return
    {children}
    } -function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) { +// 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 ( -
    -
    {label}
    -
    - {value == null || value === '' ? '—' : value} +
    +
    + {Icon && } + {label} +
    +
    + {empty ? '—' : value}
    )