From 318860a38e1ec7e9f7c5a4579d79a2c83f412250 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 16 Jun 2026 11:13:39 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User:=20H=E1=BB=93=20s=C6=A1=20Nh?= =?UTF-8?q?=C3=A2n=20s=E1=BB=B1=20master-detail=203-panel=20+=205=20tab=20?= =?UTF-8?q?(gi=E1=BB=91ng=20NamGroup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anh: bố trí Hồ sơ Nhân sự giống NamGroup HRM. Dựng lại EmployeesListPage: - Panel trái: cây tổ chức (consume GET /departments/tree) — expand/collapse + badge số đếm (totalEmployeeCount) + click lọc theo phòng. Co thành toggle màn hẹp. - Panel giữa: list (search + status filter) + avatar gradient + status badge. - Panel phải: chi tiết 5 tab (Tổng quan/Thân nhân/Trình độ/Kinh nghiệm/Hợp đồng) + avatar header lớn (gradient brand). Tổng quan 2 cột. GIỮ 100% 5 satellite CRUD (16 endpoint byte-identical grep-verified) + search + filter + query keys. Foundation màu mới + brand #1F7DC1 giữ. Build PASS (tsc -b). fe-admin mirror defer. (frontend-designer return-rỗng/#53 → em main recover từ disk + build-verify + self-gate.) Co-Authored-By: Claude Opus 4.8 (1M context) --- fe-user/src/pages/hrm/EmployeesListPage.tsx | 1358 ++++++++++++------- 1 file changed, 844 insertions(+), 514 deletions(-) diff --git a/fe-user/src/pages/hrm/EmployeesListPage.tsx b/fe-user/src/pages/hrm/EmployeesListPage.tsx index b2208b5..3138aef 100644 --- a/fe-user/src/pages/hrm/EmployeesListPage.tsx +++ b/fe-user/src/pages/hrm/EmployeesListPage.tsx @@ -1,12 +1,20 @@ -// 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) — 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. +// 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 +23,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 +36,7 @@ import { FamilyRelationKindLabel, SkillKind, SkillKindLabel, + EmployeeDocumentType, EmployeeDocumentTypeLabel, type EmployeeListItem, type EmployeeDetail, @@ -45,6 +54,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 +77,13 @@ export function EmployeesListPage() { const selectedId = sp.get('id') const [localSearch, setLocalSearch] = useState(search) + const [treeOpenMobile, setTreeOpenMobile] = useState(false) - 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 +134,349 @@ export function EmployeesListPage() { setSp(new URLSearchParams(), { replace: true }) } + // Name of the currently filtered department (for the middle-panel subtitle). + const selectedDeptName = useMemo(() => { + if (!deptFilter || !tree.data) return null + let found: string | null = null + const walk = (nodes: DepartmentTreeNode[]) => { + for (const n of nodes) { + if (n.id === deptFilter) { found = n.name; return } + walk(n.children) + if (found) return + } + } + walk(tree.data) + return found + }, [deptFilter, tree.data]) + + function pickDept(id: string | null) { + setParam('deptId', id) + setTreeOpenMobile(false) + } + return ( -
- {/* ========== LEFT PANEL: filter ========== */} -
) } -// ========== Inline forms for 5 satellite (cookie-cutter pattern 12-ter) ========== +// ===================== Inline forms for 5 satellite (cookie-cutter, UNCHANGED) ===================== const nullable = (s: string) => s.trim() || null const nullableNumber = (s: string): number | null => { @@ -808,7 +1127,7 @@ function WorkHistoryForm({ initial, onSave, onCancel, isPending }: { } return ( -
+ setForm({ ...form, companyName: e.target.value })} required maxLength={200} /> @@ -874,7 +1193,7 @@ function EducationForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, schoolName: e.target.value })} required maxLength={200} /> @@ -953,7 +1272,7 @@ function FamilyRelationForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, fullName: e.target.value })} required maxLength={200} /> @@ -1010,7 +1329,7 @@ function SkillForm({ initial, onSave, onCancel, isPending }: { } return ( - + setForm({ ...form, documentType: e.target.value })} required> @@ -1105,7 +1424,7 @@ function DocumentForm({ initial, onSave, onCancel, isPending }: { ) } -// ========== Form helpers ========== +// ===================== Form + layout helpers ===================== function FormField({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -1150,46 +1469,57 @@ 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). +function Card({ title, icon: Icon, count, action, children }: { + title: string + icon: typeof IdCard + count?: number + action?: React.ReactNode + children: React.ReactNode +}) { 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 }: { children: React.ReactNode }) { + 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 }) { +function Field({ label, value, mono, icon: Icon, full }: { + label: string + value: string | number | null + mono?: boolean + icon?: typeof Phone + full?: boolean +}) { + const empty = value == null || value === '' return ( -
-
{label}
-
- {value == null || value === '' ? '—' : value} +
+
+ {Icon && } + {label} +
+
+ {empty ? '—' : value}
)