From c3cd343bae2fb23fc2d41f669f4512a7f95ebab5 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 28 May 2026 09:38:56 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin+FE-User:=20S35=20Phase=201.?= =?UTF-8?q?5=20Item=203-FE=20=E2=80=94=20inline=20forms=205=20satellite=20?= =?UTF-8?q?cookie-cutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pattern 12-ter (within-module N-satellite) × Pattern 16-bis (cross-app mirror) cumulative 6×. FE inline expandable form Add/Edit/Delete cho 5 satellite EmployeeProfile (WorkHistory + Education + FamilyRelation + Skill + Document) — close Phase 1.5 backlog Item 3 FE side. ## Scope (Implementer Case 2 ~30K spawn) - Section component extend optional `actions?` prop (backward-compat 100%) - 5 inline form components per app: WorkHistory/Education/FamilyRelation/Skill/Document - 3 DRY helpers: FormField + FormFooter + RowActions (45 usage site) - DRY helper `invalidate()` line 268 — 15 mutations × 1 shared = clean - 5 Input types append types/employee.ts (CreateEmployeeWorkHistoryInput + 4 more) - 15 useMutation per app (5 sat × 3 verb) = 30 total cross-app - Wire BE 15 endpoint S34 ready: POST/PUT/DELETE /api/employees/{id}/{satellite-path} - gotcha #44 mitigation ACTIVE: per-action policy Hrm_HoSo.{Create|Update|Delete} ## Stats - fe-admin EmployeesListPage.tsx 573 → 1200 LOC (+627, +110%) - fe-user EmployeesListPage.tsx mirror SHA256 IDENTICAL `802d01fd1ee79925` - 2 types file +53 LOC each, SHA256 IDENTICAL `db29156a61af76e9` - npm build × 2 PASS (fe-admin 31.57s + fe-user 23.39s, 0 TS error) - BE files NOT touched (15 endpoint scaffold S34 ready) - Test gate 130 PASS baseline preserve (FE-only) ## Multi-agent ROI S35 ~70K - Implementer Case 2 ~30K (FE cookie-cutter scaffold) - Investigator ~8K (G-H2 BE CRUD pre-flight, defer Plan G-H2 next chunk) - Reviewer pre-commit ~15K Cat 1 wire BE PERFECT + em main verify Cat 2-6 PASS - CICD warm-up ~15K (Run #241 baseline verified + standby S35 push) Smart Friend 7× clean cumulative S22+S25+S29×2+S33×2+S35. Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/pages/hrm/EmployeesListPage.tsx | 825 ++++++++++++++++--- fe-admin/src/types/employee.ts | 54 ++ fe-user/src/pages/hrm/EmployeesListPage.tsx | 825 ++++++++++++++++--- fe-user/src/types/employee.ts | 54 ++ 4 files changed, 1560 insertions(+), 198 deletions(-) diff --git a/fe-admin/src/pages/hrm/EmployeesListPage.tsx b/fe-admin/src/pages/hrm/EmployeesListPage.tsx index 807aee7..b2208b5 100644 --- a/fe-admin/src/pages/hrm/EmployeesListPage.tsx +++ b/fe-admin/src/pages/hrm/EmployeesListPage.tsx @@ -1,17 +1,15 @@ // 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). +// 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' 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 { UserCircle2, Search, Plus, X, Pencil, Trash2 } from 'lucide-react' import { Input } from '@/components/ui/Input' import { Select } from '@/components/ui/Select' +import { Textarea } from '@/components/ui/Textarea' import { Button } from '@/components/ui/Button' import { EmptyState } from '@/components/EmptyState' import { api } from '@/lib/api' @@ -33,6 +31,16 @@ import { EmployeeDocumentTypeLabel, type EmployeeListItem, type EmployeeDetail, + type EmployeeWorkHistoryDto, + type EmployeeEducationDto, + type EmployeeFamilyRelationDto, + type EmployeeSkillDto, + type EmployeeDocumentDto, + type CreateEmployeeWorkHistoryInput, + type CreateEmployeeEducationInput, + type CreateEmployeeFamilyRelationInput, + type CreateEmployeeSkillInput, + type CreateEmployeeDocumentInput, } from '@/types/employee' const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') @@ -239,9 +247,121 @@ export function EmployeesListPage() { ) } -// ========== Inline 6-section read-only detail ========== +// ========== Inline 6-section detail with CRUD for satellites ========== function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) { + const qc = useQueryClient() + const employeeId = detail.id + + // State for inline add/edit forms (5 satellite) + const [addingWorkHistory, setAddingWorkHistory] = useState(false) + const [editingWorkHistoryId, setEditingWorkHistoryId] = useState(null) + const [addingEducation, setAddingEducation] = useState(false) + const [editingEducationId, setEditingEducationId] = useState(null) + const [addingFamilyRelation, setAddingFamilyRelation] = useState(false) + const [editingFamilyRelationId, setEditingFamilyRelationId] = useState(null) + const [addingSkill, setAddingSkill] = useState(false) + const [editingSkillId, setEditingSkillId] = useState(null) + const [addingDocument, setAddingDocument] = useState(false) + const [editingDocumentId, setEditingDocumentId] = useState(null) + + const invalidate = () => qc.invalidateQueries({ queryKey: ['employee-detail', employeeId] }) + + // ===== WorkHistory mutations ===== + const createWorkHistory = useMutation({ + mutationFn: async (payload: CreateEmployeeWorkHistoryInput) => + api.post(`/employees/${employeeId}/work-history`, { employeeProfileId: employeeId, ...payload }), + onSuccess: () => { toast.success('Đã thêm quá trình công tác.'); invalidate(); setAddingWorkHistory(false) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const updateWorkHistory = useMutation({ + mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeWorkHistoryInput }) => + api.put(`/employees/${employeeId}/work-history/${satId}`, { id: satId, ...payload }), + onSuccess: () => { toast.success('Đã cập nhật quá trình công tác.'); invalidate(); setEditingWorkHistoryId(null) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const deleteWorkHistory = useMutation({ + mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/work-history/${satId}`), + onSuccess: () => { toast.success('Đã xoá quá trình công tác.'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + + // ===== Education mutations ===== + const createEducation = useMutation({ + mutationFn: async (payload: CreateEmployeeEducationInput) => + api.post(`/employees/${employeeId}/education`, { employeeProfileId: employeeId, ...payload }), + onSuccess: () => { toast.success('Đã thêm quá trình đào tạo.'); invalidate(); setAddingEducation(false) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const updateEducation = useMutation({ + mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeEducationInput }) => + api.put(`/employees/${employeeId}/education/${satId}`, { id: satId, ...payload }), + onSuccess: () => { toast.success('Đã cập nhật quá trình đào tạo.'); invalidate(); setEditingEducationId(null) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const deleteEducation = useMutation({ + mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/education/${satId}`), + onSuccess: () => { toast.success('Đã xoá quá trình đào tạo.'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + + // ===== FamilyRelation mutations ===== + const createFamilyRelation = useMutation({ + mutationFn: async (payload: CreateEmployeeFamilyRelationInput) => + api.post(`/employees/${employeeId}/family-relations`, { employeeProfileId: employeeId, ...payload }), + onSuccess: () => { toast.success('Đã thêm thân nhân.'); invalidate(); setAddingFamilyRelation(false) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const updateFamilyRelation = useMutation({ + mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeFamilyRelationInput }) => + api.put(`/employees/${employeeId}/family-relations/${satId}`, { id: satId, ...payload }), + onSuccess: () => { toast.success('Đã cập nhật thân nhân.'); invalidate(); setEditingFamilyRelationId(null) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const deleteFamilyRelation = useMutation({ + mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/family-relations/${satId}`), + onSuccess: () => { toast.success('Đã xoá thân nhân.'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + + // ===== Skill mutations ===== + const createSkill = useMutation({ + mutationFn: async (payload: CreateEmployeeSkillInput) => + api.post(`/employees/${employeeId}/skills`, { employeeProfileId: employeeId, ...payload }), + onSuccess: () => { toast.success('Đã thêm kỹ năng.'); invalidate(); setAddingSkill(false) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const updateSkill = useMutation({ + mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeSkillInput }) => + api.put(`/employees/${employeeId}/skills/${satId}`, { id: satId, ...payload }), + onSuccess: () => { toast.success('Đã cập nhật kỹ năng.'); invalidate(); setEditingSkillId(null) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const deleteSkill = useMutation({ + mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/skills/${satId}`), + onSuccess: () => { toast.success('Đã xoá kỹ năng.'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + + // ===== Document mutations ===== + const createDocument = useMutation({ + mutationFn: async (payload: CreateEmployeeDocumentInput) => + api.post(`/employees/${employeeId}/documents`, { employeeProfileId: employeeId, ...payload }), + onSuccess: () => { toast.success('Đã thêm hồ sơ.'); invalidate(); setAddingDocument(false) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const updateDocument = useMutation({ + mutationFn: async ({ satId, payload }: { satId: string; payload: CreateEmployeeDocumentInput }) => + api.put(`/employees/${employeeId}/documents/${satId}`, { id: satId, ...payload }), + onSuccess: () => { toast.success('Đã cập nhật hồ sơ.'); invalidate(); setEditingDocumentId(null) }, + onError: e => toast.error(getErrorMessage(e)), + }) + const deleteDocument = useMutation({ + mutationFn: async (satId: string) => api.delete(`/employees/${employeeId}/documents/${satId}`), + onSuccess: () => { toast.success('Đã xoá hồ sơ.'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + return (
{/* Header bar */} @@ -376,91 +496,178 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; {/* Section 2: Công tác */} -
- {detail.workHistories.length === 0 ? ( +
{ 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 => ( -
-
-
{w.companyName}
-
- {fmtDate(w.fromDate)} → {fmtDate(w.toDate)} + 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) }} + />
- {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 ? ( +
{ 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 => ( -
-
-
{ed.schoolName}
-
- {fmtDate(ed.fromDate)} → {fmtDate(ed.toDate)} + editingEducationId === ed.id ? ( + updateEducation.mutate({ satId: ed.id, payload: p })} + onCancel={() => setEditingEducationId(null)} + isPending={updateEducation.isPending} + /> + ) : ( +
+
+
{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) }} + />
-
- {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 ? ( +
{ 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 => ( - - - - - - - - ))} - -
Họ tênQuan hệNăm sinhNghề nghiệpSĐT
{f.fullName}{FamilyRelationKindLabel[f.relationship]}{f.birthYear ?? '—'}{f.occupation ?? '—'}{f.phone ?? '—'}
+
+ {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 */} -
- {detail.skills.length === 0 ? ( +
{ setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}> + Thêm + + } + > + {addingSkill && ( + createSkill.mutate(p)} + onCancel={() => setAddingSkill(false)} + isPending={createSkill.isPending} + /> + )} + {detail.skills.length === 0 && !addingSkill ? ( ) : (
@@ -474,13 +681,27 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
    {group.map(s => ( -
  • -
    - {s.name} - {s.level && {s.level}} -
    - {s.languageId &&
    Mã: {s.languageId}
    } -
  • + 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) }} + /> +
  • + ) ))}
@@ -491,34 +712,51 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; {/* Section 6: Hồ sơ */} -
- {detail.documents.length === 0 ? ( +
{ 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 => ( - - - - - - - ))} - -
LoạiTên fileNgày cấpNgày hết hạn
{EmployeeDocumentTypeLabel[doc.documentType]} - - {doc.fileName} - - {fmtDate(doc.issueDate)}{fmtDate(doc.expiryDate)}
+
+ {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) }} + /> +
+ ) + ))} +
)}
@@ -530,14 +768,403 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; ) } -// ========== Helpers ========== +// ========== Inline forms for 5 satellite (cookie-cutter pattern 12-ter) ========== -function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { +const nullable = (s: string) => s.trim() || null +const nullableNumber = (s: string): number | null => { + const n = Number(s) + return s.trim() === '' || isNaN(n) ? null : n +} + +function WorkHistoryForm({ initial, onSave, onCancel, isPending }: { + initial?: EmployeeWorkHistoryDto + onSave: (p: CreateEmployeeWorkHistoryInput) => void + onCancel: () => void + isPending: boolean +}) { + const [form, setForm] = useState({ + companyName: initial?.companyName ?? '', + companyAddress: initial?.companyAddress ?? '', + industry: initial?.industry ?? '', + fromDate: initial?.fromDate ?? '', + toDate: initial?.toDate ?? '', + jobTitle: initial?.jobTitle ?? '', + jobDescription: initial?.jobDescription ?? '', + resignReason: initial?.resignReason ?? '', + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + onSave({ + companyName: form.companyName.trim(), + companyAddress: nullable(form.companyAddress), + industry: nullable(form.industry), + fromDate: form.fromDate || null, + toDate: form.toDate || null, + jobTitle: nullable(form.jobTitle), + jobDescription: nullable(form.jobDescription), + resignReason: nullable(form.resignReason), + }) + } + + return ( +
+ + + setForm({ ...form, companyName: e.target.value })} required maxLength={200} /> + + + setForm({ ...form, companyAddress: e.target.value })} maxLength={500} /> + + + setForm({ ...form, industry: e.target.value })} maxLength={100} /> + + + setForm({ ...form, jobTitle: e.target.value })} maxLength={200} /> + + + setForm({ ...form, fromDate: e.target.value })} /> + + + setForm({ ...form, toDate: e.target.value })} /> + + + +