From 9616ae219ccd403787b6fc55ae0a9408d691047a Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 26 May 2026 20:27:25 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin+FE-User:=20Plan=20B=20G-H1?= =?UTF-8?q?=20Task=205=20=E2=80=94=20EmployeesPage=202-panel=20+=20Employe?= =?UTF-8?q?eCreatePage=20cookie-cutter=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10.1 G-H1 Phase 2 Task 5 — FE 2 app cookie-cutter mirror PE pattern. Phase 1 ULTRA-MINIMAL scope (Implementer afdc812 scaffold): - 2-panel ListPage (filter left + list+detail right, KHÔNG 3-panel vì Hrm no workflow) - 6-section inline collapsible detail (Cơ bản/Công tác/Đào tạo/Thân nhân/ Kỹ năng/Hồ sơ) — NO separate DetailTabs component file - CreatePage Header form minimal (UserId picker + Status + DateOfBirth + Gender + Phone + HireDate + Nationality) - Display read-only Phase 1 satellite (no inline edit — defer Phase 1.5) ## Files (6 new + 6 modified × 2 app = 12) ### NEW (3 × 2 app, SHA256 IDENTICAL cross-app mirror) | File | LOC | SHA256 prefix | |------|----:|---| | `fe-{admin,user}/src/types/employee.ts` | 283 | CCFC70666568 | | `fe-{admin,user}/src/pages/hrm/EmployeesListPage.tsx` | 417 | DC859C897C5C | | `fe-{admin,user}/src/pages/hrm/EmployeeCreatePage.tsx` | 178 | C796F25D01AC | 10 const-object enum mirror BE Domain.Hrm.Enums + DTOs: - EmployeeStatus/Gender/MaritalStatus/EmployeeType/DegreeLevel/ EducationMode/GradeLevel/FamilyRelationKind/SkillKind/EmployeeDocumentType - EmployeeListItem + EmployeeDetail + 5 satellite DTO type ### MODIFIED (3 × 2 app) - `fe-{admin,user}/src/lib/menuKeys.ts` — +Hrm + HrmHoSo const - `fe-{admin,user}/src/components/Layout.tsx` — +Hrm_HoSo:'/employees' staticMap (LESSON Plan CA Hotfix 1 gotcha #50: page route mới phải thêm staticMap entry cùng commit, else silent sidebar drop) - `fe-{admin,user}/src/App.tsx` — +2 route /employees + /employees/new ## Pattern reinforcement - **Pattern 16-bis 4-place mirror cross-app** reinforced 4× cumulative (S29 Plan CA HF1 + S29 Plan B Chunk D + S33 Task 5 admin + S33 Task 5 user). Comment header trong Layout.tsx ghi explicit Plan CA Hotfix 1 #50 lesson. - **Pattern 12-bis cross-module entity FE port PE → Hrm** reinforced 4× (Plan B Chunk C Mig 33 + G-H1 Task 4 BE + Task 5 FE types mirror PE types/page structure mirror PE 2-panel scope-down 3→2 panel). ## Reviewer ae752c0 verdict: PASS (commit 0e191de earlier) - Smart Friend 6× cumulative clean (em main + Implementer quality genuine) - gotcha #50 Layout staticMap mirror ✓ (cả fe-admin + fe-user) - menuKeys.ts FE drift pre-existing intentional (fe-admin minimal vs fe-user expanded Catalogs/Suppliers/Projects/Departments) — NOT blocking, follow-up task add Budgets/Catalogs to fe-admin OR document intentional minimal scope. ## Verify - fe-admin npm build: PASS 21.4s · 0 TS6 err · 1,431 KB bundle - fe-user npm build: PASS 9.2s · 0 TS6 err · 1,345 KB bundle - dotnet build: PASS 1.59s · 0 warn 0 err (no BE change) - dotnet test: 120/120 PASS baseline preserved ## Defer Phase 1.5 (per Reviewer recommend) 1. PermissionGuard wrapper menuKey HrmHoSo + per-action Hrm_HoSo_View/Create 2. Convert 3 bool field UpdateCommand thành bool? safe partial update 3. Satellite CRUD endpoint + form (WorkHistory/Education/FamilyRelation/ Skill/Document) 4. Test bundle (Create UNIQUE conflict + List filter + codeGen race) 5. Add Bg_*/Catalog* to fe-admin menuKeys.ts sync với fe-user Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/App.tsx | 5 + fe-admin/src/components/Layout.tsx | 4 + fe-admin/src/lib/menuKeys.ts | 3 + fe-admin/src/pages/hrm/EmployeeCreatePage.tsx | 205 +++++++ fe-admin/src/pages/hrm/EmployeesListPage.tsx | 573 ++++++++++++++++++ fe-admin/src/types/employee.ts | 306 ++++++++++ fe-user/src/App.tsx | 5 + fe-user/src/components/Layout.tsx | 4 + fe-user/src/lib/menuKeys.ts | 3 + fe-user/src/pages/hrm/EmployeeCreatePage.tsx | 205 +++++++ fe-user/src/pages/hrm/EmployeesListPage.tsx | 573 ++++++++++++++++++ fe-user/src/types/employee.ts | 306 ++++++++++ 12 files changed, 2192 insertions(+) create mode 100644 fe-admin/src/pages/hrm/EmployeeCreatePage.tsx create mode 100644 fe-admin/src/pages/hrm/EmployeesListPage.tsx create mode 100644 fe-admin/src/types/employee.ts create mode 100644 fe-user/src/pages/hrm/EmployeeCreatePage.tsx create mode 100644 fe-user/src/pages/hrm/EmployeesListPage.tsx create mode 100644 fe-user/src/types/employee.ts diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index d5e700c..06bfabf 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -26,6 +26,8 @@ import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreat import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage' import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage' import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' +import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' +import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' function App() { return ( @@ -68,6 +70,9 @@ function App() { } /> } /> } /> + {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} + } /> + } /> } /> } /> new Date().toISOString().slice(0, 10) + +const initial: CreateForm = { + userId: '', + employeeStatus: String(EmployeeStatus.Active), + hireDate: todayIso(), + dateOfBirth: '', + gender: '', + phone: '', + nationality: 'Việt Nam', +} + +const PHONE_RE = /^0\d{9,10}$/ +const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) + +export function EmployeeCreatePage() { + const navigate = useNavigate() + const [form, setForm] = useState(initial) + + // Lấy list user để pick — pageSize lớn cho admin pick dễ. + const users = useQuery({ + queryKey: ['users-for-employee-create'], + queryFn: async () => + (await api.get>('/users', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const create = useMutation({ + mutationFn: async () => { + const body = { + userId: form.userId, + employeeStatus: Number(form.employeeStatus), + hireDate: form.hireDate || null, + dateOfBirth: form.dateOfBirth || null, + gender: form.gender ? Number(form.gender) : null, + phone: form.phone.trim() || null, + nationality: form.nationality.trim() || null, + } + return (await api.post<{ id: string }>('/employees', body)).data + }, + onSuccess: data => { + toast.success('Đã tạo hồ sơ NV.') + navigate(`/employees?id=${data.id}`) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + function submit(e: FormEvent) { + e.preventDefault() + if (!form.userId) { + toast.error('Vui lòng chọn user.') + return + } + if (!isValidPhone(form.phone)) { + toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).') + return + } + create.mutate() + } + + return ( +
+
+ +

Tạo Hồ sơ Nhân sự mới

+
+ +
+
+ + +

+ Mỗi user chỉ được link với 1 hồ sơ NV. Tạo user mới ở mục System > Users nếu thiếu. +

+
+ +
+
+ + +
+ +
+ + setForm(f => ({ ...f, hireDate: e.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm(f => ({ ...f, dateOfBirth: e.target.value }))} + /> +
+ +
+ + setForm(f => ({ ...f, phone: e.target.value }))} + placeholder="0912345678" + /> +
+ +
+ + setForm(f => ({ ...f, nationality: e.target.value }))} + /> +
+
+ +
+ + +
+ +

+ Các thông tin chi tiết (giấy tờ, địa chỉ, lương, kỹ năng, ...) có thể bổ sung ở mục Sửa hồ sơ sau khi tạo. +

+
+
+ ) +} diff --git a/fe-admin/src/pages/hrm/EmployeesListPage.tsx b/fe-admin/src/pages/hrm/EmployeesListPage.tsx new file mode 100644 index 0000000..807aee7 --- /dev/null +++ b/fe-admin/src/pages/hrm/EmployeesListPage.tsx @@ -0,0 +1,573 @@ +// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail. +// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5): +// - Read-only mọi section (Edit Header defer Phase 1.5) +// - 6 section render inline trong right panel qua `
` HTML native +// - NO separate DetailTabs component, NO satellite CRUD form +// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33). +// URL params: id (selected), q (search), status, deptId +import { 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 } from 'lucide-react' +import { Input } from '@/components/ui/Input' +import { Select } from '@/components/ui/Select' +import { Button } from '@/components/ui/Button' +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 { + EmployeeStatusColor, + EmployeeStatusLabel, + GenderLabel, + MaritalStatusLabel, + EmployeeTypeLabel, + DegreeLevelLabel, + EducationModeLabel, + GradeLevelLabel, + FamilyRelationKindLabel, + SkillKind, + SkillKindLabel, + EmployeeDocumentTypeLabel, + type EmployeeListItem, + type EmployeeDetail, +} from '@/types/employee' + +const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') +const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—') + +export function EmployeesListPage() { + const navigate = useNavigate() + const qc = useQueryClient() + const [sp, setSp] = useSearchParams() + const search = sp.get('q') ?? '' + const statusFilter = sp.get('status') ?? '' + const deptFilter = sp.get('deptId') ?? '' + const selectedId = sp.get('id') + + const [localSearch, setLocalSearch] = useState(search) + + const departments = useQuery({ + queryKey: ['departments-all-hrm'], + queryFn: async () => + (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const list = useQuery({ + queryKey: ['employees-list', { search, statusFilter, deptFilter }], + queryFn: async () => { + const res = await api.get>('/employees', { + params: { + pageSize: 100, + search: search || undefined, + status: statusFilter || undefined, + departmentId: deptFilter || undefined, + }, + }) + return res.data + }, + }) + + const detail = useQuery({ + queryKey: ['employee-detail', selectedId], + queryFn: async () => (await api.get(`/employees/${selectedId}`)).data, + enabled: !!selectedId, + }) + + const del = useMutation({ + mutationFn: async (id: string) => api.delete(`/employees/${id}`), + onSuccess: () => { + toast.success('Đã xoá hồ sơ NV.') + setParam('id', null) + qc.invalidateQueries({ queryKey: ['employees-list'] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + function setParam(key: string, value: string | null) { + const next = new URLSearchParams(sp) + if (value == null || value === '') next.delete(key) + else next.set(key, value) + setSp(next, { replace: true }) + } + + function applySearch(e: React.FormEvent) { + e.preventDefault() + setParam('q', localSearch.trim() || null) + } + + function resetFilters() { + setLocalSearch('') + 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ơ.
+ ) : ( + del.mutate(detail.data!.id)} /> + )} +
+ )} +
+
+ ) +} + +// ========== Inline 6-section read-only detail ========== + +function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) { + return ( +
+ {/* Header bar */} +
+
+
+ {detail.photoUrl ? ( + {detail.fullName + ) : ( + + )} +
+
+

{detail.fullName ?? '—'}

+
+ {detail.employeeCode} + + + {EmployeeStatusLabel[detail.employeeStatus]} + + {detail.departmentName && ( + <> + + {detail.departmentName} + + )} +
+
+
+ +
+ + {/* Section 1: Cơ bản */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {detail.notes && ( + +

{detail.notes}

+
+ )} +
+ + {/* Section 2: Công tác */} +
+ {detail.workHistories.length === 0 ? ( + + ) : ( +
+ {detail.workHistories.map(w => ( +
+
+
{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}
} +
+ ))} +
+ )} +
+ + {/* Section 3: Đào tạo */} +
+ {detail.educations.length === 0 ? ( + + ) : ( +
+ {detail.educations.map(ed => ( +
+
+
{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}
} +
+ ))} +
+ )} +
+ + {/* Section 4: Thân nhân */} +
+ {detail.familyRelations.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {detail.familyRelations.map(f => ( + + + + + + + + ))} + +
Họ tênQuan hệNăm sinhNghề nghiệpSĐT
{f.fullName}{FamilyRelationKindLabel[f.relationship]}{f.birthYear ?? '—'}{f.occupation ?? '—'}{f.phone ?? '—'}
+ )} +
+ + {/* Section 5: Kỹ năng */} +
+ {detail.skills.length === 0 ? ( + + ) : ( +
+ {[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 => ( +
  • +
    + {s.name} + {s.level && {s.level}} +
    + {s.languageId &&
    Mã: {s.languageId}
    } +
  • + ))} +
+
+ ) + })} +
+ )} +
+ + {/* Section 6: Hồ sơ */} +
+ {detail.documents.length === 0 ? ( + + ) : ( + + + + + + + + + + + {detail.documents.map(doc => ( + + + + + + + ))} + +
LoạiTên fileNgày cấpNgày hết hạn
{EmployeeDocumentTypeLabel[doc.documentType]} + + {doc.fileName} + + {fmtDate(doc.issueDate)}{fmtDate(doc.expiryDate)}
+ )} +
+ +
+ Tạo: {fmtDateTime(detail.createdAt)} + {detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}} +
+
+ ) +} + +// ========== Helpers ========== + +function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { + return ( +
+ + {title} + + +
{children}
+
+ ) +} + +function SubBlock({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +function Grid2({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) { + return ( +
+
{label}
+
+ {value == null || value === '' ? '—' : value} +
+
+ ) +} + +function EmptyHint({ text }: { text: string }) { + return
{text}
+} diff --git a/fe-admin/src/types/employee.ts b/fe-admin/src/types/employee.ts new file mode 100644 index 0000000..c07180e --- /dev/null +++ b/fe-admin/src/types/employee.ts @@ -0,0 +1,306 @@ +// Types cho module Hồ sơ Nhân sự (HRM) — mirror BE Domain.Hrm.Enums + DTOs. +// Phase 10.1 G-H1 (S33) — Pattern 12-bis cross-module FE port PE → Hrm (4th +// reinforcement). TS6 erasableSyntaxOnly cấm enum → const-object pattern bắt buộc. + +// ========== Enum mirror BE Domain.Hrm.Enums (10 enum) ========== + +export const EmployeeStatus = { + Active: 1, + OnLeave: 2, + Resigned: 3, +} as const +export type EmployeeStatus = typeof EmployeeStatus[keyof typeof EmployeeStatus] + +export const EmployeeStatusLabel: Record = { + 1: 'Đang làm việc', + 2: 'Nghỉ phép', + 3: 'Đã nghỉ việc', +} + +export const EmployeeStatusColor: Record = { + 1: 'bg-emerald-100 text-emerald-700', + 2: 'bg-amber-100 text-amber-700', + 3: 'bg-slate-100 text-slate-600', +} + +export const Gender = { + Male: 1, + Female: 2, + Other: 3, +} as const +export type Gender = typeof Gender[keyof typeof Gender] + +export const GenderLabel: Record = { + 1: 'Nam', + 2: 'Nữ', + 3: 'Khác', +} + +export const MaritalStatus = { + Single: 1, + Married: 2, + Divorced: 3, + Widowed: 4, +} as const +export type MaritalStatus = typeof MaritalStatus[keyof typeof MaritalStatus] + +export const MaritalStatusLabel: Record = { + 1: 'Độc thân', + 2: 'Đã kết hôn', + 3: 'Đã ly hôn', + 4: 'Goá', +} + +export const EmployeeType = { + FullTime: 1, + PartTime: 2, + Intern: 3, + Contractor: 4, +} as const +export type EmployeeType = typeof EmployeeType[keyof typeof EmployeeType] + +export const EmployeeTypeLabel: Record = { + 1: 'Chính thức', + 2: 'Bán thời gian', + 3: 'Thực tập', + 4: 'Khoán việc', +} + +export const DegreeLevel = { + College: 1, + Bachelor: 2, + Master: 3, + PhD: 4, +} as const +export type DegreeLevel = typeof DegreeLevel[keyof typeof DegreeLevel] + +export const DegreeLevelLabel: Record = { + 1: 'Cao đẳng', + 2: 'Đại học', + 3: 'Thạc sĩ', + 4: 'Tiến sĩ', +} + +export const EducationMode = { + FullTime: 1, + PartTime: 2, + Distance: 3, +} as const +export type EducationMode = typeof EducationMode[keyof typeof EducationMode] + +export const EducationModeLabel: Record = { + 1: 'Chính quy', + 2: 'Tại chức', + 3: 'Từ xa', +} + +export const GradeLevel = { + Average: 1, + Good: 2, + Excellent: 3, +} as const +export type GradeLevel = typeof GradeLevel[keyof typeof GradeLevel] + +export const GradeLevelLabel: Record = { + 1: 'Trung bình', + 2: 'Khá', + 3: 'Giỏi', +} + +export const FamilyRelationKind = { + Father: 1, + Mother: 2, + Spouse: 3, + Child: 4, + Sibling: 5, + Other: 99, +} as const +export type FamilyRelationKind = typeof FamilyRelationKind[keyof typeof FamilyRelationKind] + +export const FamilyRelationKindLabel: Record = { + 1: 'Cha', + 2: 'Mẹ', + 3: 'Vợ/Chồng', + 4: 'Con', + 5: 'Anh/Chị/Em ruột', + 99: 'Khác', +} + +export const SkillKind = { + Computer: 1, + Language: 2, + Other: 3, +} as const +export type SkillKind = typeof SkillKind[keyof typeof SkillKind] + +export const SkillKindLabel: Record = { + 1: 'Kỹ năng vi tính', + 2: 'Ngoại ngữ', + 3: 'Kỹ năng khác', +} + +export const EmployeeDocumentType = { + IdCard: 1, + Passport: 2, + Degree: 3, + Certificate: 4, + LaborContract: 5, + Other: 99, +} as const +export type EmployeeDocumentType = typeof EmployeeDocumentType[keyof typeof EmployeeDocumentType] + +export const EmployeeDocumentTypeLabel: Record = { + 1: 'CMND/CCCD', + 2: 'Hộ chiếu', + 3: 'Bằng cấp', + 4: 'Chứng chỉ', + 5: 'HĐLĐ', + 99: 'Khác', +} + +// ========== List item (paged) ========== + +export type EmployeeListItem = { + id: string + employeeCode: string + userId: string + fullName: string | null + email: string | null + departmentId: string | null + departmentName: string | null + status: number + phone: string | null + hireDate: string | null + createdAt: string + updatedAt: string | null +} + +// ========== Satellite read DTOs (inline GetDetail bundle) ========== + +export type EmployeeWorkHistoryDto = { + id: string + companyName: string + companyAddress: string | null + industry: string | null + fromDate: string | null + toDate: string | null + jobTitle: string | null + jobDescription: string | null + resignReason: string | null +} + +export type EmployeeEducationDto = { + id: string + schoolName: string + major: string | null + degreeLevel: number | null + educationMode: number | null + gradeLevel: number | null + fromDate: string | null + toDate: string | null + certificateIssueDate: string | null + notes: string | null +} + +export type EmployeeFamilyRelationDto = { + id: string + fullName: string + relationship: number + birthYear: number | null + occupation: string | null + currentAddress: string | null + phone: string | null +} + +export type EmployeeSkillDto = { + id: string + kind: number + name: string + languageId: string | null + level: string | null +} + +export type EmployeeDocumentDto = { + id: string + documentType: number + fileName: string + filePath: string + fileSize: number + contentType: string + issueDate: string | null + expiryDate: string | null + notes: string | null +} + +// ========== Detail (full + 5 satellite collection) ========== + +export type EmployeeDetail = { + id: string + employeeCode: string + userId: string + fullName: string | null + email: string | null + departmentId: string | null + departmentName: string | null + employeeStatus: number + gender: number | null + maritalStatus: number | null + employeeType: number | null + dateOfBirth: string | null + birthPlace: string | null + hometown: string | null + phone: string | null + personalEmail: string | null + internalPhone: string | null + ethnicity: string | null + religion: string | null + nationality: string | null + idCardNumber: string | null + idCardIssueDate: string | null + idCardIssuePlace: string | null + taxCode: string | null + socialInsuranceNumber: string | null + passportNumber: string | null + permanentAddressText: string | null + streetAddressPermanent: string | null + temporaryAddressText: string | null + streetAddressTemporary: string | null + hireDate: string | null + resignDate: string | null + emergencyContactName: string | null + emergencyContactPhone: string | null + emergencyContactAddress: string | null + qualification: string | null + academicTitle: string | null + workLocation: string | null + timekeepingCode: string | null + bankAccount: string | null + bankName: string | null + bankBranch: string | null + heightCm: number | null + weightKg: number | null + bloodType: string | null + baseSalary: number | null + totalSalary: number | null + annualLeaveDays: number | null + remainingLeaveDays: number | null + compensatoryLeaveDays: number | null + seniorityLeaveDays: number | null + socialInsuranceStartDate: string | null + medicalRegistrationPlace: string | null + isCommunistParty: boolean + communistPartyJoinDate: string | null + isYouthUnion: boolean + youthUnionJoinDate: string | null + isTradeUnion: boolean + tradeUnionJoinDate: string | null + photoUrl: string | null + notes: string | null + createdAt: string + updatedAt: string | null + workHistories: EmployeeWorkHistoryDto[] + educations: EmployeeEducationDto[] + familyRelations: EmployeeFamilyRelationDto[] + skills: EmployeeSkillDto[] + documents: EmployeeDocumentDto[] +} diff --git a/fe-user/src/App.tsx b/fe-user/src/App.tsx index 7c8cf01..3437871 100644 --- a/fe-user/src/App.tsx +++ b/fe-user/src/App.tsx @@ -19,6 +19,8 @@ import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWo import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage' import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage' import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' +import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' +import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' function App() { return ( @@ -51,6 +53,9 @@ function App() { } /> } /> } /> + {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} + } /> + } /> } /> new Date().toISOString().slice(0, 10) + +const initial: CreateForm = { + userId: '', + employeeStatus: String(EmployeeStatus.Active), + hireDate: todayIso(), + dateOfBirth: '', + gender: '', + phone: '', + nationality: 'Việt Nam', +} + +const PHONE_RE = /^0\d{9,10}$/ +const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) + +export function EmployeeCreatePage() { + const navigate = useNavigate() + const [form, setForm] = useState(initial) + + // Lấy list user để pick — pageSize lớn cho admin pick dễ. + const users = useQuery({ + queryKey: ['users-for-employee-create'], + queryFn: async () => + (await api.get>('/users', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const create = useMutation({ + mutationFn: async () => { + const body = { + userId: form.userId, + employeeStatus: Number(form.employeeStatus), + hireDate: form.hireDate || null, + dateOfBirth: form.dateOfBirth || null, + gender: form.gender ? Number(form.gender) : null, + phone: form.phone.trim() || null, + nationality: form.nationality.trim() || null, + } + return (await api.post<{ id: string }>('/employees', body)).data + }, + onSuccess: data => { + toast.success('Đã tạo hồ sơ NV.') + navigate(`/employees?id=${data.id}`) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + function submit(e: FormEvent) { + e.preventDefault() + if (!form.userId) { + toast.error('Vui lòng chọn user.') + return + } + if (!isValidPhone(form.phone)) { + toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).') + return + } + create.mutate() + } + + return ( +
+
+ +

Tạo Hồ sơ Nhân sự mới

+
+ +
+
+ + +

+ Mỗi user chỉ được link với 1 hồ sơ NV. Tạo user mới ở mục System > Users nếu thiếu. +

+
+ +
+
+ + +
+ +
+ + setForm(f => ({ ...f, hireDate: e.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm(f => ({ ...f, dateOfBirth: e.target.value }))} + /> +
+ +
+ + setForm(f => ({ ...f, phone: e.target.value }))} + placeholder="0912345678" + /> +
+ +
+ + setForm(f => ({ ...f, nationality: e.target.value }))} + /> +
+
+ +
+ + +
+ +

+ Các thông tin chi tiết (giấy tờ, địa chỉ, lương, kỹ năng, ...) có thể bổ sung ở mục Sửa hồ sơ sau khi tạo. +

+
+
+ ) +} diff --git a/fe-user/src/pages/hrm/EmployeesListPage.tsx b/fe-user/src/pages/hrm/EmployeesListPage.tsx new file mode 100644 index 0000000..807aee7 --- /dev/null +++ b/fe-user/src/pages/hrm/EmployeesListPage.tsx @@ -0,0 +1,573 @@ +// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail. +// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5): +// - Read-only mọi section (Edit Header defer Phase 1.5) +// - 6 section render inline trong right panel qua `
` HTML native +// - NO separate DetailTabs component, NO satellite CRUD form +// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33). +// URL params: id (selected), q (search), status, deptId +import { 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 } from 'lucide-react' +import { Input } from '@/components/ui/Input' +import { Select } from '@/components/ui/Select' +import { Button } from '@/components/ui/Button' +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 { + EmployeeStatusColor, + EmployeeStatusLabel, + GenderLabel, + MaritalStatusLabel, + EmployeeTypeLabel, + DegreeLevelLabel, + EducationModeLabel, + GradeLevelLabel, + FamilyRelationKindLabel, + SkillKind, + SkillKindLabel, + EmployeeDocumentTypeLabel, + type EmployeeListItem, + type EmployeeDetail, +} from '@/types/employee' + +const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') +const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—') + +export function EmployeesListPage() { + const navigate = useNavigate() + const qc = useQueryClient() + const [sp, setSp] = useSearchParams() + const search = sp.get('q') ?? '' + const statusFilter = sp.get('status') ?? '' + const deptFilter = sp.get('deptId') ?? '' + const selectedId = sp.get('id') + + const [localSearch, setLocalSearch] = useState(search) + + const departments = useQuery({ + queryKey: ['departments-all-hrm'], + queryFn: async () => + (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const list = useQuery({ + queryKey: ['employees-list', { search, statusFilter, deptFilter }], + queryFn: async () => { + const res = await api.get>('/employees', { + params: { + pageSize: 100, + search: search || undefined, + status: statusFilter || undefined, + departmentId: deptFilter || undefined, + }, + }) + return res.data + }, + }) + + const detail = useQuery({ + queryKey: ['employee-detail', selectedId], + queryFn: async () => (await api.get(`/employees/${selectedId}`)).data, + enabled: !!selectedId, + }) + + const del = useMutation({ + mutationFn: async (id: string) => api.delete(`/employees/${id}`), + onSuccess: () => { + toast.success('Đã xoá hồ sơ NV.') + setParam('id', null) + qc.invalidateQueries({ queryKey: ['employees-list'] }) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + + function setParam(key: string, value: string | null) { + const next = new URLSearchParams(sp) + if (value == null || value === '') next.delete(key) + else next.set(key, value) + setSp(next, { replace: true }) + } + + function applySearch(e: React.FormEvent) { + e.preventDefault() + setParam('q', localSearch.trim() || null) + } + + function resetFilters() { + setLocalSearch('') + 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ơ.
+ ) : ( + del.mutate(detail.data!.id)} /> + )} +
+ )} +
+
+ ) +} + +// ========== Inline 6-section read-only detail ========== + +function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) { + return ( +
+ {/* Header bar */} +
+
+
+ {detail.photoUrl ? ( + {detail.fullName + ) : ( + + )} +
+
+

{detail.fullName ?? '—'}

+
+ {detail.employeeCode} + + + {EmployeeStatusLabel[detail.employeeStatus]} + + {detail.departmentName && ( + <> + + {detail.departmentName} + + )} +
+
+
+ +
+ + {/* Section 1: Cơ bản */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {detail.notes && ( + +

{detail.notes}

+
+ )} +
+ + {/* Section 2: Công tác */} +
+ {detail.workHistories.length === 0 ? ( + + ) : ( +
+ {detail.workHistories.map(w => ( +
+
+
{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}
} +
+ ))} +
+ )} +
+ + {/* Section 3: Đào tạo */} +
+ {detail.educations.length === 0 ? ( + + ) : ( +
+ {detail.educations.map(ed => ( +
+
+
{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}
} +
+ ))} +
+ )} +
+ + {/* Section 4: Thân nhân */} +
+ {detail.familyRelations.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {detail.familyRelations.map(f => ( + + + + + + + + ))} + +
Họ tênQuan hệNăm sinhNghề nghiệpSĐT
{f.fullName}{FamilyRelationKindLabel[f.relationship]}{f.birthYear ?? '—'}{f.occupation ?? '—'}{f.phone ?? '—'}
+ )} +
+ + {/* Section 5: Kỹ năng */} +
+ {detail.skills.length === 0 ? ( + + ) : ( +
+ {[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 => ( +
  • +
    + {s.name} + {s.level && {s.level}} +
    + {s.languageId &&
    Mã: {s.languageId}
    } +
  • + ))} +
+
+ ) + })} +
+ )} +
+ + {/* Section 6: Hồ sơ */} +
+ {detail.documents.length === 0 ? ( + + ) : ( + + + + + + + + + + + {detail.documents.map(doc => ( + + + + + + + ))} + +
LoạiTên fileNgày cấpNgày hết hạn
{EmployeeDocumentTypeLabel[doc.documentType]} + + {doc.fileName} + + {fmtDate(doc.issueDate)}{fmtDate(doc.expiryDate)}
+ )} +
+ +
+ Tạo: {fmtDateTime(detail.createdAt)} + {detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}} +
+
+ ) +} + +// ========== Helpers ========== + +function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { + return ( +
+ + {title} + + +
{children}
+
+ ) +} + +function SubBlock({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +function Grid2({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) { + return ( +
+
{label}
+
+ {value == null || value === '' ? '—' : value} +
+
+ ) +} + +function EmptyHint({ text }: { text: string }) { + return
{text}
+} diff --git a/fe-user/src/types/employee.ts b/fe-user/src/types/employee.ts new file mode 100644 index 0000000..c07180e --- /dev/null +++ b/fe-user/src/types/employee.ts @@ -0,0 +1,306 @@ +// Types cho module Hồ sơ Nhân sự (HRM) — mirror BE Domain.Hrm.Enums + DTOs. +// Phase 10.1 G-H1 (S33) — Pattern 12-bis cross-module FE port PE → Hrm (4th +// reinforcement). TS6 erasableSyntaxOnly cấm enum → const-object pattern bắt buộc. + +// ========== Enum mirror BE Domain.Hrm.Enums (10 enum) ========== + +export const EmployeeStatus = { + Active: 1, + OnLeave: 2, + Resigned: 3, +} as const +export type EmployeeStatus = typeof EmployeeStatus[keyof typeof EmployeeStatus] + +export const EmployeeStatusLabel: Record = { + 1: 'Đang làm việc', + 2: 'Nghỉ phép', + 3: 'Đã nghỉ việc', +} + +export const EmployeeStatusColor: Record = { + 1: 'bg-emerald-100 text-emerald-700', + 2: 'bg-amber-100 text-amber-700', + 3: 'bg-slate-100 text-slate-600', +} + +export const Gender = { + Male: 1, + Female: 2, + Other: 3, +} as const +export type Gender = typeof Gender[keyof typeof Gender] + +export const GenderLabel: Record = { + 1: 'Nam', + 2: 'Nữ', + 3: 'Khác', +} + +export const MaritalStatus = { + Single: 1, + Married: 2, + Divorced: 3, + Widowed: 4, +} as const +export type MaritalStatus = typeof MaritalStatus[keyof typeof MaritalStatus] + +export const MaritalStatusLabel: Record = { + 1: 'Độc thân', + 2: 'Đã kết hôn', + 3: 'Đã ly hôn', + 4: 'Goá', +} + +export const EmployeeType = { + FullTime: 1, + PartTime: 2, + Intern: 3, + Contractor: 4, +} as const +export type EmployeeType = typeof EmployeeType[keyof typeof EmployeeType] + +export const EmployeeTypeLabel: Record = { + 1: 'Chính thức', + 2: 'Bán thời gian', + 3: 'Thực tập', + 4: 'Khoán việc', +} + +export const DegreeLevel = { + College: 1, + Bachelor: 2, + Master: 3, + PhD: 4, +} as const +export type DegreeLevel = typeof DegreeLevel[keyof typeof DegreeLevel] + +export const DegreeLevelLabel: Record = { + 1: 'Cao đẳng', + 2: 'Đại học', + 3: 'Thạc sĩ', + 4: 'Tiến sĩ', +} + +export const EducationMode = { + FullTime: 1, + PartTime: 2, + Distance: 3, +} as const +export type EducationMode = typeof EducationMode[keyof typeof EducationMode] + +export const EducationModeLabel: Record = { + 1: 'Chính quy', + 2: 'Tại chức', + 3: 'Từ xa', +} + +export const GradeLevel = { + Average: 1, + Good: 2, + Excellent: 3, +} as const +export type GradeLevel = typeof GradeLevel[keyof typeof GradeLevel] + +export const GradeLevelLabel: Record = { + 1: 'Trung bình', + 2: 'Khá', + 3: 'Giỏi', +} + +export const FamilyRelationKind = { + Father: 1, + Mother: 2, + Spouse: 3, + Child: 4, + Sibling: 5, + Other: 99, +} as const +export type FamilyRelationKind = typeof FamilyRelationKind[keyof typeof FamilyRelationKind] + +export const FamilyRelationKindLabel: Record = { + 1: 'Cha', + 2: 'Mẹ', + 3: 'Vợ/Chồng', + 4: 'Con', + 5: 'Anh/Chị/Em ruột', + 99: 'Khác', +} + +export const SkillKind = { + Computer: 1, + Language: 2, + Other: 3, +} as const +export type SkillKind = typeof SkillKind[keyof typeof SkillKind] + +export const SkillKindLabel: Record = { + 1: 'Kỹ năng vi tính', + 2: 'Ngoại ngữ', + 3: 'Kỹ năng khác', +} + +export const EmployeeDocumentType = { + IdCard: 1, + Passport: 2, + Degree: 3, + Certificate: 4, + LaborContract: 5, + Other: 99, +} as const +export type EmployeeDocumentType = typeof EmployeeDocumentType[keyof typeof EmployeeDocumentType] + +export const EmployeeDocumentTypeLabel: Record = { + 1: 'CMND/CCCD', + 2: 'Hộ chiếu', + 3: 'Bằng cấp', + 4: 'Chứng chỉ', + 5: 'HĐLĐ', + 99: 'Khác', +} + +// ========== List item (paged) ========== + +export type EmployeeListItem = { + id: string + employeeCode: string + userId: string + fullName: string | null + email: string | null + departmentId: string | null + departmentName: string | null + status: number + phone: string | null + hireDate: string | null + createdAt: string + updatedAt: string | null +} + +// ========== Satellite read DTOs (inline GetDetail bundle) ========== + +export type EmployeeWorkHistoryDto = { + id: string + companyName: string + companyAddress: string | null + industry: string | null + fromDate: string | null + toDate: string | null + jobTitle: string | null + jobDescription: string | null + resignReason: string | null +} + +export type EmployeeEducationDto = { + id: string + schoolName: string + major: string | null + degreeLevel: number | null + educationMode: number | null + gradeLevel: number | null + fromDate: string | null + toDate: string | null + certificateIssueDate: string | null + notes: string | null +} + +export type EmployeeFamilyRelationDto = { + id: string + fullName: string + relationship: number + birthYear: number | null + occupation: string | null + currentAddress: string | null + phone: string | null +} + +export type EmployeeSkillDto = { + id: string + kind: number + name: string + languageId: string | null + level: string | null +} + +export type EmployeeDocumentDto = { + id: string + documentType: number + fileName: string + filePath: string + fileSize: number + contentType: string + issueDate: string | null + expiryDate: string | null + notes: string | null +} + +// ========== Detail (full + 5 satellite collection) ========== + +export type EmployeeDetail = { + id: string + employeeCode: string + userId: string + fullName: string | null + email: string | null + departmentId: string | null + departmentName: string | null + employeeStatus: number + gender: number | null + maritalStatus: number | null + employeeType: number | null + dateOfBirth: string | null + birthPlace: string | null + hometown: string | null + phone: string | null + personalEmail: string | null + internalPhone: string | null + ethnicity: string | null + religion: string | null + nationality: string | null + idCardNumber: string | null + idCardIssueDate: string | null + idCardIssuePlace: string | null + taxCode: string | null + socialInsuranceNumber: string | null + passportNumber: string | null + permanentAddressText: string | null + streetAddressPermanent: string | null + temporaryAddressText: string | null + streetAddressTemporary: string | null + hireDate: string | null + resignDate: string | null + emergencyContactName: string | null + emergencyContactPhone: string | null + emergencyContactAddress: string | null + qualification: string | null + academicTitle: string | null + workLocation: string | null + timekeepingCode: string | null + bankAccount: string | null + bankName: string | null + bankBranch: string | null + heightCm: number | null + weightKg: number | null + bloodType: string | null + baseSalary: number | null + totalSalary: number | null + annualLeaveDays: number | null + remainingLeaveDays: number | null + compensatoryLeaveDays: number | null + seniorityLeaveDays: number | null + socialInsuranceStartDate: string | null + medicalRegistrationPlace: string | null + isCommunistParty: boolean + communistPartyJoinDate: string | null + isYouthUnion: boolean + youthUnionJoinDate: string | null + isTradeUnion: boolean + tradeUnionJoinDate: string | null + photoUrl: string | null + notes: string | null + createdAt: string + updatedAt: string | null + workHistories: EmployeeWorkHistoryDto[] + educations: EmployeeEducationDto[] + familyRelations: EmployeeFamilyRelationDto[] + skills: EmployeeSkillDto[] + documents: EmployeeDocumentDto[] +}