[CLAUDE] FE-Admin+FE-User: S35 Phase 1.5 Item 3-FE — inline forms 5 satellite cookie-cutter
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m51s

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) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 09:38:56 +07:00
parent 63dd9ecf94
commit c3cd343bae
4 changed files with 1560 additions and 198 deletions

View File

@ -1,17 +1,15 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail. // 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): // Phase 10.1 G-H1 Phase 1.5 (S35 Plan B-WrapPlus1) — inline forms 5 satellite cookie-cutter.
// - Read-only mọi section (Edit Header defer Phase 1.5) // Pattern 12-ter × 16-bis 6th reinforcement (S35). 5 inline forms × 2 app mirror SHA256 IDENTICAL.
// - 6 section render inline trong right panel qua `<details>` 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 // URL params: id (selected), q (search), status, deptId
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' 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 { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -33,6 +31,16 @@ import {
EmployeeDocumentTypeLabel, EmployeeDocumentTypeLabel,
type EmployeeListItem, type EmployeeListItem,
type EmployeeDetail, 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' } from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') 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 }) { 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<string | null>(null)
const [addingEducation, setAddingEducation] = useState(false)
const [editingEducationId, setEditingEducationId] = useState<string | null>(null)
const [addingFamilyRelation, setAddingFamilyRelation] = useState(false)
const [editingFamilyRelationId, setEditingFamilyRelationId] = useState<string | null>(null)
const [addingSkill, setAddingSkill] = useState(false)
const [editingSkillId, setEditingSkillId] = useState<string | null>(null)
const [addingDocument, setAddingDocument] = useState(false)
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(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 ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Header bar */} {/* Header bar */}
@ -376,13 +496,36 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</Section> </Section>
{/* Section 2: Công tác */} {/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}> <Section
{detail.workHistories.length === 0 ? ( title={`2. Quá trình công tác (${detail.workHistories.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingWorkHistory && (
<WorkHistoryForm
onSave={p => createWorkHistory.mutate(p)}
onCancel={() => setAddingWorkHistory(false)}
isPending={createWorkHistory.isPending}
/>
)}
{detail.workHistories.length === 0 && !addingWorkHistory ? (
<EmptyHint text="Chưa có quá trình công tác nào." /> <EmptyHint text="Chưa có quá trình công tác nào." />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{detail.workHistories.map(w => ( {detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm"> editingWorkHistoryId === w.id ? (
<WorkHistoryForm
key={w.id}
initial={w}
onSave={p => updateWorkHistory.mutate({ satId: w.id, payload: p })}
onCancel={() => setEditingWorkHistoryId(null)}
isPending={updateWorkHistory.isPending}
/>
) : (
<div key={w.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div> <div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -394,20 +537,48 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
{w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>} {w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>}
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>} {w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>} {w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
<RowActions
onEdit={() => { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }}
onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }}
/>
</div> </div>
)
))} ))}
</div> </div>
)} )}
</Section> </Section>
{/* Section 3: Đào tạo */} {/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}> <Section
{detail.educations.length === 0 ? ( title={`3. Quá trình đào tạo (${detail.educations.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingEducation && (
<EducationForm
onSave={p => createEducation.mutate(p)}
onCancel={() => setAddingEducation(false)}
isPending={createEducation.isPending}
/>
)}
{detail.educations.length === 0 && !addingEducation ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." /> <EmptyHint text="Chưa có quá trình đào tạo nào." />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{detail.educations.map(ed => ( {detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm"> editingEducationId === ed.id ? (
<EducationForm
key={ed.id}
initial={ed}
onSave={p => updateEducation.mutate({ satId: ed.id, payload: p })}
onCancel={() => setEditingEducationId(null)}
isPending={updateEducation.isPending}
/>
) : (
<div key={ed.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div> <div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -422,45 +593,81 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</div> </div>
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>} {ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>} {ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
<RowActions
onEdit={() => { setEditingEducationId(ed.id); setAddingEducation(false) }}
onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }}
/>
</div> </div>
)
))} ))}
</div> </div>
)} )}
</Section> </Section>
{/* Section 4: Thân nhân */} {/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}> <Section
{detail.familyRelations.length === 0 ? ( title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingFamilyRelation && (
<FamilyRelationForm
onSave={p => createFamilyRelation.mutate(p)}
onCancel={() => setAddingFamilyRelation(false)}
isPending={createFamilyRelation.isPending}
/>
)}
{detail.familyRelations.length === 0 && !addingFamilyRelation ? (
<EmptyHint text="Chưa có thông tin thân nhân." /> <EmptyHint text="Chưa có thông tin thân nhân." />
) : ( ) : (
<table className="w-full text-sm"> <div className="space-y-2">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{detail.familyRelations.map(f => ( {detail.familyRelations.map(f => (
<tr key={f.id} className="border-b border-slate-100"> editingFamilyRelationId === f.id ? (
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td> <FamilyRelationForm
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td> key={f.id}
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td> initial={f}
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td> onSave={p => updateFamilyRelation.mutate({ satId: f.id, payload: p })}
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td> onCancel={() => setEditingFamilyRelationId(null)}
</tr> isPending={updateFamilyRelation.isPending}
/>
) : (
<div key={f.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-5">
<div><span className="text-xs text-slate-500">Họ tên: </span><span className="font-medium text-slate-800">{f.fullName}</span></div>
<div><span className="text-xs text-slate-500">Quan hệ: </span>{FamilyRelationKindLabel[f.relationship]}</div>
<div><span className="text-xs text-slate-500">Năm sinh: </span>{f.birthYear ?? '—'}</div>
<div><span className="text-xs text-slate-500">Nghề: </span>{f.occupation ?? '—'}</div>
<div><span className="text-xs text-slate-500">SĐT: </span>{f.phone ?? '—'}</div>
<RowActions
onEdit={() => { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }}
onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }}
/>
</div>
)
))} ))}
</tbody> </div>
</table>
)} )}
</Section> </Section>
{/* Section 5: Kỹ năng */} {/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}> <Section
{detail.skills.length === 0 ? ( title={`5. Kỹ năng (${detail.skills.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingSkill && (
<SkillForm
onSave={p => createSkill.mutate(p)}
onCancel={() => setAddingSkill(false)}
isPending={createSkill.isPending}
/>
)}
{detail.skills.length === 0 && !addingSkill ? (
<EmptyHint text="Chưa có thông tin kỹ năng." /> <EmptyHint text="Chưa có thông tin kỹ năng." />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -474,13 +681,27 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{group.map(s => ( {group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm"> editingSkillId === s.id ? (
<SkillForm
key={s.id}
initial={s}
onSave={p => updateSkill.mutate({ satId: s.id, payload: p })}
onCancel={() => setEditingSkillId(null)}
isPending={updateSkill.isPending}
/>
) : (
<li key={s.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span> <span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>} {s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div> </div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>} {s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
<RowActions
onEdit={() => { setEditingSkillId(s.id); setAddingSkill(false) }}
onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }}
/>
</li> </li>
)
))} ))}
</ul> </ul>
</div> </div>
@ -491,34 +712,51 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</Section> </Section>
{/* Section 6: Hồ sơ */} {/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}> <Section
{detail.documents.length === 0 ? ( title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingDocument && (
<DocumentForm
onSave={p => createDocument.mutate(p)}
onCancel={() => setAddingDocument(false)}
isPending={createDocument.isPending}
/>
)}
{detail.documents.length === 0 && !addingDocument ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." /> <EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : ( ) : (
<table className="w-full text-sm"> <div className="space-y-2">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Loại</th>
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
</tr>
</thead>
<tbody>
{detail.documents.map(doc => ( {detail.documents.map(doc => (
<tr key={doc.id} className="border-b border-slate-100"> editingDocumentId === doc.id ? (
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td> <DocumentForm
<td className="py-2 pr-3"> key={doc.id}
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline"> initial={doc}
{doc.fileName} onSave={p => updateDocument.mutate({ satId: doc.id, payload: p })}
</a> onCancel={() => setEditingDocumentId(null)}
</td> isPending={updateDocument.isPending}
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td> />
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td> ) : (
</tr> <div key={doc.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-4">
<div><span className="text-xs text-slate-500">Loại: </span>{EmployeeDocumentTypeLabel[doc.documentType]}</div>
<div>
<span className="text-xs text-slate-500">Tên file: </span>
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">{doc.fileName}</a>
</div>
<div><span className="text-xs text-slate-500">Ngày cấp: </span>{fmtDate(doc.issueDate)}</div>
<div><span className="text-xs text-slate-500">Hết hạn: </span>{fmtDate(doc.expiryDate)}</div>
<RowActions
onEdit={() => { setEditingDocumentId(doc.id); setAddingDocument(false) }}
onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }}
/>
</div>
)
))} ))}
</tbody> </div>
</table>
)} )}
</Section> </Section>
@ -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 (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Tên công ty *">
<Input value={form.companyName} onChange={e => setForm({ ...form, companyName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Địa chỉ công ty">
<Input value={form.companyAddress} onChange={e => setForm({ ...form, companyAddress: e.target.value })} maxLength={500} />
</FormField>
<FormField label="Ngành nghề">
<Input value={form.industry} onChange={e => setForm({ ...form, industry: e.target.value })} maxLength={100} />
</FormField>
<FormField label="Chức vụ">
<Input value={form.jobTitle} onChange={e => setForm({ ...form, jobTitle: e.target.value })} maxLength={200} />
</FormField>
<FormField label="Từ ngày">
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
</FormField>
<FormField label="Đến ngày">
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Mô tả công việc">
<Textarea value={form.jobDescription} onChange={e => setForm({ ...form, jobDescription: e.target.value })} maxLength={2000} rows={2} />
</FormField>
<FormField label="Lý do nghỉ">
<Input value={form.resignReason} onChange={e => setForm({ ...form, resignReason: e.target.value })} maxLength={500} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function EducationForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeEducationDto
onSave: (p: CreateEmployeeEducationInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
schoolName: initial?.schoolName ?? '',
major: initial?.major ?? '',
degreeLevel: initial?.degreeLevel != null ? String(initial.degreeLevel) : '',
educationMode: initial?.educationMode != null ? String(initial.educationMode) : '',
gradeLevel: initial?.gradeLevel != null ? String(initial.gradeLevel) : '',
fromDate: initial?.fromDate ?? '',
toDate: initial?.toDate ?? '',
certificateIssueDate: initial?.certificateIssueDate ?? '',
notes: initial?.notes ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
schoolName: form.schoolName.trim(),
major: nullable(form.major),
degreeLevel: nullableNumber(form.degreeLevel),
educationMode: nullableNumber(form.educationMode),
gradeLevel: nullableNumber(form.gradeLevel),
fromDate: form.fromDate || null,
toDate: form.toDate || null,
certificateIssueDate: form.certificateIssueDate || null,
notes: nullable(form.notes),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Tên trường *">
<Input value={form.schoolName} onChange={e => setForm({ ...form, schoolName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Chuyên ngành">
<Input value={form.major} onChange={e => setForm({ ...form, major: e.target.value })} maxLength={200} />
</FormField>
<FormField label="Bằng cấp">
<Select value={form.degreeLevel} onChange={e => setForm({ ...form, degreeLevel: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Cao đng</option>
<option value="2">Đi học</option>
<option value="3">Thạc </option>
<option value="4">Tiến </option>
</Select>
</FormField>
<FormField label="Hình thức">
<Select value={form.educationMode} onChange={e => setForm({ ...form, educationMode: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Chính quy</option>
<option value="2">Tại chức</option>
<option value="3">Từ xa</option>
</Select>
</FormField>
<FormField label="Xếp loại">
<Select value={form.gradeLevel} onChange={e => setForm({ ...form, gradeLevel: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Trung bình</option>
<option value="2">Khá</option>
<option value="3">Giỏi</option>
</Select>
</FormField>
<FormField label="Ngày cấp bằng">
<Input type="date" value={form.certificateIssueDate} onChange={e => setForm({ ...form, certificateIssueDate: e.target.value })} />
</FormField>
<FormField label="Từ ngày">
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
</FormField>
<FormField label="Đến ngày">
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Ghi chú">
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function FamilyRelationForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeFamilyRelationDto
onSave: (p: CreateEmployeeFamilyRelationInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
fullName: initial?.fullName ?? '',
relationship: initial?.relationship != null ? String(initial.relationship) : '1',
birthYear: initial?.birthYear != null ? String(initial.birthYear) : '',
occupation: initial?.occupation ?? '',
currentAddress: initial?.currentAddress ?? '',
phone: initial?.phone ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
fullName: form.fullName.trim(),
relationship: Number(form.relationship),
birthYear: nullableNumber(form.birthYear),
occupation: nullable(form.occupation),
currentAddress: nullable(form.currentAddress),
phone: nullable(form.phone),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Họ tên *">
<Input value={form.fullName} onChange={e => setForm({ ...form, fullName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Quan hệ *">
<Select value={form.relationship} onChange={e => setForm({ ...form, relationship: e.target.value })} required>
<option value="1">Cha</option>
<option value="2">Mẹ</option>
<option value="3">Vợ/Chồng</option>
<option value="4">Con</option>
<option value="5">Anh/Chị/Em ruột</option>
<option value="99">Khác</option>
</Select>
</FormField>
<FormField label="Năm sinh">
<Input type="number" min={1900} max={2026} value={form.birthYear} onChange={e => setForm({ ...form, birthYear: e.target.value })} />
</FormField>
<FormField label="Nghề nghiệp">
<Input value={form.occupation} onChange={e => setForm({ ...form, occupation: e.target.value })} maxLength={200} />
</FormField>
<FormField label="SĐT">
<Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} maxLength={20} />
</FormField>
<FormField label="Địa chỉ hiện tại">
<Input value={form.currentAddress} onChange={e => setForm({ ...form, currentAddress: e.target.value })} maxLength={500} />
</FormField>
</Grid2>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function SkillForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeSkillDto
onSave: (p: CreateEmployeeSkillInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
kind: initial?.kind != null ? String(initial.kind) : '1',
name: initial?.name ?? '',
languageId: initial?.languageId ?? '',
level: initial?.level ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
kind: Number(form.kind),
name: form.name.trim(),
languageId: nullable(form.languageId),
level: nullable(form.level),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Loại *">
<Select value={form.kind} onChange={e => setForm({ ...form, kind: e.target.value })} required>
<option value="1">Kỹ năng vi tính</option>
<option value="2">Ngoại ngữ</option>
<option value="3">Kỹ năng khác</option>
</Select>
</FormField>
<FormField label="Tên kỹ năng *">
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Mã ngôn ngữ">
<Input value={form.languageId} onChange={e => setForm({ ...form, languageId: e.target.value })} maxLength={10} placeholder="vi/en/ja..." />
</FormField>
<FormField label="Trình độ">
<Input value={form.level} onChange={e => setForm({ ...form, level: e.target.value })} maxLength={200} placeholder="B1/IELTS 6.5/N2..." />
</FormField>
</Grid2>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function DocumentForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeDocumentDto
onSave: (p: CreateEmployeeDocumentInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
documentType: initial?.documentType != null ? String(initial.documentType) : '1',
fileName: initial?.fileName ?? '',
filePath: initial?.filePath ?? '',
fileSize: initial?.fileSize != null ? String(initial.fileSize) : '0',
contentType: initial?.contentType ?? 'application/octet-stream',
issueDate: initial?.issueDate ?? '',
expiryDate: initial?.expiryDate ?? '',
notes: initial?.notes ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
documentType: Number(form.documentType),
fileName: form.fileName.trim(),
filePath: form.filePath.trim(),
fileSize: Number(form.fileSize) || 0,
contentType: form.contentType.trim(),
issueDate: form.issueDate || null,
expiryDate: form.expiryDate || null,
notes: nullable(form.notes),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Loại hồ sơ *">
<Select value={form.documentType} onChange={e => setForm({ ...form, documentType: e.target.value })} required>
<option value="1">CMND/CCCD</option>
<option value="2">Hộ chiếu</option>
<option value="3">Bằng cấp</option>
<option value="4">Chứng chỉ</option>
<option value="5">HĐLĐ</option>
<option value="99">Khác</option>
</Select>
</FormField>
<FormField label="Tên file *">
<Input value={form.fileName} onChange={e => setForm({ ...form, fileName: e.target.value })} required maxLength={500} />
</FormField>
<FormField label="Đường dẫn file *">
<Input value={form.filePath} onChange={e => setForm({ ...form, filePath: e.target.value })} required maxLength={1000} />
</FormField>
<FormField label="Content-Type *">
<Input value={form.contentType} onChange={e => setForm({ ...form, contentType: e.target.value })} required maxLength={100} />
</FormField>
<FormField label="Kích thước (bytes)">
<Input type="number" min={0} value={form.fileSize} onChange={e => setForm({ ...form, fileSize: e.target.value })} />
</FormField>
<FormField label="Ngày cấp">
<Input type="date" value={form.issueDate} onChange={e => setForm({ ...form, issueDate: e.target.value })} />
</FormField>
<FormField label="Ngày hết hạn">
<Input type="date" value={form.expiryDate} onChange={e => setForm({ ...form, expiryDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Ghi chú">
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
// ========== Form helpers ==========
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-slate-700">{label}</label>
{children}
</div>
)
}
function FormFooter({ onCancel, isPending, isEdit }: { onCancel: () => void; isPending: boolean; isEdit: boolean }) {
return (
<div className="flex justify-end gap-2 border-t border-brand-100 pt-2">
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>Huỷ</Button>
<Button type="submit" size="sm" disabled={isPending}>
{isPending ? 'Đang lưu...' : (isEdit ? 'Cập nhật' : 'Thêm')}
</Button>
</div>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="absolute right-2 top-2 flex gap-1">
<button
type="button"
onClick={onEdit}
className="rounded p-1 text-slate-400 transition hover:bg-brand-50 hover:text-brand-600"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onDelete}
className="rounded p-1 text-slate-400 transition hover:bg-red-50 hover:text-red-600"
title="Xoá"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// ========== Layout helpers (read-only) ==========
function Section({ title, children, defaultOpen, actions }: { title: string; children: React.ReactNode; defaultOpen?: boolean; actions?: React.ReactNode }) {
return ( return (
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white"> <details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50"> <summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
<span>{title}</span> <span className="flex items-center gap-2">
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span> <span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
<span>{title}</span>
</span>
{actions && (
<span onClick={e => e.stopPropagation()}>
{actions}
</span>
)}
</summary> </summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div> <div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details> </details>

View File

@ -232,6 +232,60 @@ export type EmployeeDocumentDto = {
notes: string | null notes: string | null
} }
// ========== Satellite Create/Update Input Commands (S35 Plan B-WrapPlus1) ==========
// Mirror BE record Command field shape (EmployeeSatelliteFeatures.cs). UpdateInput
// reuse CreateInput shape (DRY) — handler set `id` separately at API call site.
export type CreateEmployeeWorkHistoryInput = {
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 CreateEmployeeEducationInput = {
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 CreateEmployeeFamilyRelationInput = {
fullName: string
relationship: number
birthYear: number | null
occupation: string | null
currentAddress: string | null
phone: string | null
}
export type CreateEmployeeSkillInput = {
kind: number
name: string
languageId: string | null
level: string | null
}
export type CreateEmployeeDocumentInput = {
documentType: number
fileName: string
filePath: string
fileSize: number
contentType: string
issueDate: string | null
expiryDate: string | null
notes: string | null
}
// ========== Detail (full + 5 satellite collection) ========== // ========== Detail (full + 5 satellite collection) ==========
export type EmployeeDetail = { export type EmployeeDetail = {

View File

@ -1,17 +1,15 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail. // 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): // Phase 10.1 G-H1 Phase 1.5 (S35 Plan B-WrapPlus1) — inline forms 5 satellite cookie-cutter.
// - Read-only mọi section (Edit Header defer Phase 1.5) // Pattern 12-ter × 16-bis 6th reinforcement (S35). 5 inline forms × 2 app mirror SHA256 IDENTICAL.
// - 6 section render inline trong right panel qua `<details>` 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 // URL params: id (selected), q (search), status, deptId
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' 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 { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -33,6 +31,16 @@ import {
EmployeeDocumentTypeLabel, EmployeeDocumentTypeLabel,
type EmployeeListItem, type EmployeeListItem,
type EmployeeDetail, 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' } from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—') 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 }) { 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<string | null>(null)
const [addingEducation, setAddingEducation] = useState(false)
const [editingEducationId, setEditingEducationId] = useState<string | null>(null)
const [addingFamilyRelation, setAddingFamilyRelation] = useState(false)
const [editingFamilyRelationId, setEditingFamilyRelationId] = useState<string | null>(null)
const [addingSkill, setAddingSkill] = useState(false)
const [editingSkillId, setEditingSkillId] = useState<string | null>(null)
const [addingDocument, setAddingDocument] = useState(false)
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(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 ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Header bar */} {/* Header bar */}
@ -376,13 +496,36 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</Section> </Section>
{/* Section 2: Công tác */} {/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}> <Section
{detail.workHistories.length === 0 ? ( title={`2. Quá trình công tác (${detail.workHistories.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingWorkHistory(true); setEditingWorkHistoryId(null) }} disabled={addingWorkHistory}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingWorkHistory && (
<WorkHistoryForm
onSave={p => createWorkHistory.mutate(p)}
onCancel={() => setAddingWorkHistory(false)}
isPending={createWorkHistory.isPending}
/>
)}
{detail.workHistories.length === 0 && !addingWorkHistory ? (
<EmptyHint text="Chưa có quá trình công tác nào." /> <EmptyHint text="Chưa có quá trình công tác nào." />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{detail.workHistories.map(w => ( {detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm"> editingWorkHistoryId === w.id ? (
<WorkHistoryForm
key={w.id}
initial={w}
onSave={p => updateWorkHistory.mutate({ satId: w.id, payload: p })}
onCancel={() => setEditingWorkHistoryId(null)}
isPending={updateWorkHistory.isPending}
/>
) : (
<div key={w.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div> <div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -394,20 +537,48 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
{w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>} {w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>}
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>} {w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>} {w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
<RowActions
onEdit={() => { setEditingWorkHistoryId(w.id); setAddingWorkHistory(false) }}
onDelete={() => { if (confirm(`Xoá quá trình công tác tại "${w.companyName}"?`)) deleteWorkHistory.mutate(w.id) }}
/>
</div> </div>
)
))} ))}
</div> </div>
)} )}
</Section> </Section>
{/* Section 3: Đào tạo */} {/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}> <Section
{detail.educations.length === 0 ? ( title={`3. Quá trình đào tạo (${detail.educations.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingEducation(true); setEditingEducationId(null) }} disabled={addingEducation}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingEducation && (
<EducationForm
onSave={p => createEducation.mutate(p)}
onCancel={() => setAddingEducation(false)}
isPending={createEducation.isPending}
/>
)}
{detail.educations.length === 0 && !addingEducation ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." /> <EmptyHint text="Chưa có quá trình đào tạo nào." />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{detail.educations.map(ed => ( {detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm"> editingEducationId === ed.id ? (
<EducationForm
key={ed.id}
initial={ed}
onSave={p => updateEducation.mutate({ satId: ed.id, payload: p })}
onCancel={() => setEditingEducationId(null)}
isPending={updateEducation.isPending}
/>
) : (
<div key={ed.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div> <div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -422,45 +593,81 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</div> </div>
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>} {ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>} {ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
<RowActions
onEdit={() => { setEditingEducationId(ed.id); setAddingEducation(false) }}
onDelete={() => { if (confirm(`Xoá quá trình đào tạo tại "${ed.schoolName}"?`)) deleteEducation.mutate(ed.id) }}
/>
</div> </div>
)
))} ))}
</div> </div>
)} )}
</Section> </Section>
{/* Section 4: Thân nhân */} {/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}> <Section
{detail.familyRelations.length === 0 ? ( title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingFamilyRelation(true); setEditingFamilyRelationId(null) }} disabled={addingFamilyRelation}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingFamilyRelation && (
<FamilyRelationForm
onSave={p => createFamilyRelation.mutate(p)}
onCancel={() => setAddingFamilyRelation(false)}
isPending={createFamilyRelation.isPending}
/>
)}
{detail.familyRelations.length === 0 && !addingFamilyRelation ? (
<EmptyHint text="Chưa có thông tin thân nhân." /> <EmptyHint text="Chưa có thông tin thân nhân." />
) : ( ) : (
<table className="w-full text-sm"> <div className="space-y-2">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{detail.familyRelations.map(f => ( {detail.familyRelations.map(f => (
<tr key={f.id} className="border-b border-slate-100"> editingFamilyRelationId === f.id ? (
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td> <FamilyRelationForm
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td> key={f.id}
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td> initial={f}
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td> onSave={p => updateFamilyRelation.mutate({ satId: f.id, payload: p })}
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td> onCancel={() => setEditingFamilyRelationId(null)}
</tr> isPending={updateFamilyRelation.isPending}
/>
) : (
<div key={f.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-5">
<div><span className="text-xs text-slate-500">Họ tên: </span><span className="font-medium text-slate-800">{f.fullName}</span></div>
<div><span className="text-xs text-slate-500">Quan hệ: </span>{FamilyRelationKindLabel[f.relationship]}</div>
<div><span className="text-xs text-slate-500">Năm sinh: </span>{f.birthYear ?? '—'}</div>
<div><span className="text-xs text-slate-500">Nghề: </span>{f.occupation ?? '—'}</div>
<div><span className="text-xs text-slate-500">SĐT: </span>{f.phone ?? '—'}</div>
<RowActions
onEdit={() => { setEditingFamilyRelationId(f.id); setAddingFamilyRelation(false) }}
onDelete={() => { if (confirm(`Xoá thân nhân "${f.fullName}"?`)) deleteFamilyRelation.mutate(f.id) }}
/>
</div>
)
))} ))}
</tbody> </div>
</table>
)} )}
</Section> </Section>
{/* Section 5: Kỹ năng */} {/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}> <Section
{detail.skills.length === 0 ? ( title={`5. Kỹ năng (${detail.skills.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingSkill(true); setEditingSkillId(null) }} disabled={addingSkill}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingSkill && (
<SkillForm
onSave={p => createSkill.mutate(p)}
onCancel={() => setAddingSkill(false)}
isPending={createSkill.isPending}
/>
)}
{detail.skills.length === 0 && !addingSkill ? (
<EmptyHint text="Chưa có thông tin kỹ năng." /> <EmptyHint text="Chưa có thông tin kỹ năng." />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -474,13 +681,27 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{group.map(s => ( {group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm"> editingSkillId === s.id ? (
<SkillForm
key={s.id}
initial={s}
onSave={p => updateSkill.mutate({ satId: s.id, payload: p })}
onCancel={() => setEditingSkillId(null)}
isPending={updateSkill.isPending}
/>
) : (
<li key={s.id} className="relative rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 pr-16 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span> <span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>} {s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div> </div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>} {s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
<RowActions
onEdit={() => { setEditingSkillId(s.id); setAddingSkill(false) }}
onDelete={() => { if (confirm(`Xoá kỹ năng "${s.name}"?`)) deleteSkill.mutate(s.id) }}
/>
</li> </li>
)
))} ))}
</ul> </ul>
</div> </div>
@ -491,34 +712,51 @@ function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail;
</Section> </Section>
{/* Section 6: Hồ sơ */} {/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}> <Section
{detail.documents.length === 0 ? ( title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}
actions={
<Button size="sm" variant="outline" onClick={() => { setAddingDocument(true); setEditingDocumentId(null) }} disabled={addingDocument}>
<Plus className="h-3.5 w-3.5" /> Thêm
</Button>
}
>
{addingDocument && (
<DocumentForm
onSave={p => createDocument.mutate(p)}
onCancel={() => setAddingDocument(false)}
isPending={createDocument.isPending}
/>
)}
{detail.documents.length === 0 && !addingDocument ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." /> <EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : ( ) : (
<table className="w-full text-sm"> <div className="space-y-2">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Loại</th>
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
</tr>
</thead>
<tbody>
{detail.documents.map(doc => ( {detail.documents.map(doc => (
<tr key={doc.id} className="border-b border-slate-100"> editingDocumentId === doc.id ? (
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td> <DocumentForm
<td className="py-2 pr-3"> key={doc.id}
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline"> initial={doc}
{doc.fileName} onSave={p => updateDocument.mutate({ satId: doc.id, payload: p })}
</a> onCancel={() => setEditingDocumentId(null)}
</td> isPending={updateDocument.isPending}
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td> />
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td> ) : (
</tr> <div key={doc.id} className="relative grid grid-cols-1 gap-1 rounded-md border border-slate-200 bg-slate-50/50 p-3 pr-16 text-sm md:grid-cols-4">
<div><span className="text-xs text-slate-500">Loại: </span>{EmployeeDocumentTypeLabel[doc.documentType]}</div>
<div>
<span className="text-xs text-slate-500">Tên file: </span>
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">{doc.fileName}</a>
</div>
<div><span className="text-xs text-slate-500">Ngày cấp: </span>{fmtDate(doc.issueDate)}</div>
<div><span className="text-xs text-slate-500">Hết hạn: </span>{fmtDate(doc.expiryDate)}</div>
<RowActions
onEdit={() => { setEditingDocumentId(doc.id); setAddingDocument(false) }}
onDelete={() => { if (confirm(`Xoá hồ sơ "${doc.fileName}"?`)) deleteDocument.mutate(doc.id) }}
/>
</div>
)
))} ))}
</tbody> </div>
</table>
)} )}
</Section> </Section>
@ -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 (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Tên công ty *">
<Input value={form.companyName} onChange={e => setForm({ ...form, companyName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Địa chỉ công ty">
<Input value={form.companyAddress} onChange={e => setForm({ ...form, companyAddress: e.target.value })} maxLength={500} />
</FormField>
<FormField label="Ngành nghề">
<Input value={form.industry} onChange={e => setForm({ ...form, industry: e.target.value })} maxLength={100} />
</FormField>
<FormField label="Chức vụ">
<Input value={form.jobTitle} onChange={e => setForm({ ...form, jobTitle: e.target.value })} maxLength={200} />
</FormField>
<FormField label="Từ ngày">
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
</FormField>
<FormField label="Đến ngày">
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Mô tả công việc">
<Textarea value={form.jobDescription} onChange={e => setForm({ ...form, jobDescription: e.target.value })} maxLength={2000} rows={2} />
</FormField>
<FormField label="Lý do nghỉ">
<Input value={form.resignReason} onChange={e => setForm({ ...form, resignReason: e.target.value })} maxLength={500} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function EducationForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeEducationDto
onSave: (p: CreateEmployeeEducationInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
schoolName: initial?.schoolName ?? '',
major: initial?.major ?? '',
degreeLevel: initial?.degreeLevel != null ? String(initial.degreeLevel) : '',
educationMode: initial?.educationMode != null ? String(initial.educationMode) : '',
gradeLevel: initial?.gradeLevel != null ? String(initial.gradeLevel) : '',
fromDate: initial?.fromDate ?? '',
toDate: initial?.toDate ?? '',
certificateIssueDate: initial?.certificateIssueDate ?? '',
notes: initial?.notes ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
schoolName: form.schoolName.trim(),
major: nullable(form.major),
degreeLevel: nullableNumber(form.degreeLevel),
educationMode: nullableNumber(form.educationMode),
gradeLevel: nullableNumber(form.gradeLevel),
fromDate: form.fromDate || null,
toDate: form.toDate || null,
certificateIssueDate: form.certificateIssueDate || null,
notes: nullable(form.notes),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Tên trường *">
<Input value={form.schoolName} onChange={e => setForm({ ...form, schoolName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Chuyên ngành">
<Input value={form.major} onChange={e => setForm({ ...form, major: e.target.value })} maxLength={200} />
</FormField>
<FormField label="Bằng cấp">
<Select value={form.degreeLevel} onChange={e => setForm({ ...form, degreeLevel: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Cao đng</option>
<option value="2">Đi học</option>
<option value="3">Thạc </option>
<option value="4">Tiến </option>
</Select>
</FormField>
<FormField label="Hình thức">
<Select value={form.educationMode} onChange={e => setForm({ ...form, educationMode: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Chính quy</option>
<option value="2">Tại chức</option>
<option value="3">Từ xa</option>
</Select>
</FormField>
<FormField label="Xếp loại">
<Select value={form.gradeLevel} onChange={e => setForm({ ...form, gradeLevel: e.target.value })}>
<option value=""> Chọn </option>
<option value="1">Trung bình</option>
<option value="2">Khá</option>
<option value="3">Giỏi</option>
</Select>
</FormField>
<FormField label="Ngày cấp bằng">
<Input type="date" value={form.certificateIssueDate} onChange={e => setForm({ ...form, certificateIssueDate: e.target.value })} />
</FormField>
<FormField label="Từ ngày">
<Input type="date" value={form.fromDate} onChange={e => setForm({ ...form, fromDate: e.target.value })} />
</FormField>
<FormField label="Đến ngày">
<Input type="date" value={form.toDate} onChange={e => setForm({ ...form, toDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Ghi chú">
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function FamilyRelationForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeFamilyRelationDto
onSave: (p: CreateEmployeeFamilyRelationInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
fullName: initial?.fullName ?? '',
relationship: initial?.relationship != null ? String(initial.relationship) : '1',
birthYear: initial?.birthYear != null ? String(initial.birthYear) : '',
occupation: initial?.occupation ?? '',
currentAddress: initial?.currentAddress ?? '',
phone: initial?.phone ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
fullName: form.fullName.trim(),
relationship: Number(form.relationship),
birthYear: nullableNumber(form.birthYear),
occupation: nullable(form.occupation),
currentAddress: nullable(form.currentAddress),
phone: nullable(form.phone),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Họ tên *">
<Input value={form.fullName} onChange={e => setForm({ ...form, fullName: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Quan hệ *">
<Select value={form.relationship} onChange={e => setForm({ ...form, relationship: e.target.value })} required>
<option value="1">Cha</option>
<option value="2">Mẹ</option>
<option value="3">Vợ/Chồng</option>
<option value="4">Con</option>
<option value="5">Anh/Chị/Em ruột</option>
<option value="99">Khác</option>
</Select>
</FormField>
<FormField label="Năm sinh">
<Input type="number" min={1900} max={2026} value={form.birthYear} onChange={e => setForm({ ...form, birthYear: e.target.value })} />
</FormField>
<FormField label="Nghề nghiệp">
<Input value={form.occupation} onChange={e => setForm({ ...form, occupation: e.target.value })} maxLength={200} />
</FormField>
<FormField label="SĐT">
<Input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} maxLength={20} />
</FormField>
<FormField label="Địa chỉ hiện tại">
<Input value={form.currentAddress} onChange={e => setForm({ ...form, currentAddress: e.target.value })} maxLength={500} />
</FormField>
</Grid2>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function SkillForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeSkillDto
onSave: (p: CreateEmployeeSkillInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
kind: initial?.kind != null ? String(initial.kind) : '1',
name: initial?.name ?? '',
languageId: initial?.languageId ?? '',
level: initial?.level ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
kind: Number(form.kind),
name: form.name.trim(),
languageId: nullable(form.languageId),
level: nullable(form.level),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Loại *">
<Select value={form.kind} onChange={e => setForm({ ...form, kind: e.target.value })} required>
<option value="1">Kỹ năng vi tính</option>
<option value="2">Ngoại ngữ</option>
<option value="3">Kỹ năng khác</option>
</Select>
</FormField>
<FormField label="Tên kỹ năng *">
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} required maxLength={200} />
</FormField>
<FormField label="Mã ngôn ngữ">
<Input value={form.languageId} onChange={e => setForm({ ...form, languageId: e.target.value })} maxLength={10} placeholder="vi/en/ja..." />
</FormField>
<FormField label="Trình độ">
<Input value={form.level} onChange={e => setForm({ ...form, level: e.target.value })} maxLength={200} placeholder="B1/IELTS 6.5/N2..." />
</FormField>
</Grid2>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
function DocumentForm({ initial, onSave, onCancel, isPending }: {
initial?: EmployeeDocumentDto
onSave: (p: CreateEmployeeDocumentInput) => void
onCancel: () => void
isPending: boolean
}) {
const [form, setForm] = useState({
documentType: initial?.documentType != null ? String(initial.documentType) : '1',
fileName: initial?.fileName ?? '',
filePath: initial?.filePath ?? '',
fileSize: initial?.fileSize != null ? String(initial.fileSize) : '0',
contentType: initial?.contentType ?? 'application/octet-stream',
issueDate: initial?.issueDate ?? '',
expiryDate: initial?.expiryDate ?? '',
notes: initial?.notes ?? '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave({
documentType: Number(form.documentType),
fileName: form.fileName.trim(),
filePath: form.filePath.trim(),
fileSize: Number(form.fileSize) || 0,
contentType: form.contentType.trim(),
issueDate: form.issueDate || null,
expiryDate: form.expiryDate || null,
notes: nullable(form.notes),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-3 rounded-md border-2 border-brand-200 bg-brand-50/40 p-3">
<Grid2>
<FormField label="Loại hồ sơ *">
<Select value={form.documentType} onChange={e => setForm({ ...form, documentType: e.target.value })} required>
<option value="1">CMND/CCCD</option>
<option value="2">Hộ chiếu</option>
<option value="3">Bằng cấp</option>
<option value="4">Chứng chỉ</option>
<option value="5">HĐLĐ</option>
<option value="99">Khác</option>
</Select>
</FormField>
<FormField label="Tên file *">
<Input value={form.fileName} onChange={e => setForm({ ...form, fileName: e.target.value })} required maxLength={500} />
</FormField>
<FormField label="Đường dẫn file *">
<Input value={form.filePath} onChange={e => setForm({ ...form, filePath: e.target.value })} required maxLength={1000} />
</FormField>
<FormField label="Content-Type *">
<Input value={form.contentType} onChange={e => setForm({ ...form, contentType: e.target.value })} required maxLength={100} />
</FormField>
<FormField label="Kích thước (bytes)">
<Input type="number" min={0} value={form.fileSize} onChange={e => setForm({ ...form, fileSize: e.target.value })} />
</FormField>
<FormField label="Ngày cấp">
<Input type="date" value={form.issueDate} onChange={e => setForm({ ...form, issueDate: e.target.value })} />
</FormField>
<FormField label="Ngày hết hạn">
<Input type="date" value={form.expiryDate} onChange={e => setForm({ ...form, expiryDate: e.target.value })} />
</FormField>
</Grid2>
<FormField label="Ghi chú">
<Textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} maxLength={1000} rows={2} />
</FormField>
<FormFooter onCancel={onCancel} isPending={isPending} isEdit={!!initial} />
</form>
)
}
// ========== Form helpers ==========
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-slate-700">{label}</label>
{children}
</div>
)
}
function FormFooter({ onCancel, isPending, isEdit }: { onCancel: () => void; isPending: boolean; isEdit: boolean }) {
return (
<div className="flex justify-end gap-2 border-t border-brand-100 pt-2">
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isPending}>Huỷ</Button>
<Button type="submit" size="sm" disabled={isPending}>
{isPending ? 'Đang lưu...' : (isEdit ? 'Cập nhật' : 'Thêm')}
</Button>
</div>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="absolute right-2 top-2 flex gap-1">
<button
type="button"
onClick={onEdit}
className="rounded p-1 text-slate-400 transition hover:bg-brand-50 hover:text-brand-600"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onDelete}
className="rounded p-1 text-slate-400 transition hover:bg-red-50 hover:text-red-600"
title="Xoá"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// ========== Layout helpers (read-only) ==========
function Section({ title, children, defaultOpen, actions }: { title: string; children: React.ReactNode; defaultOpen?: boolean; actions?: React.ReactNode }) {
return ( return (
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white"> <details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50"> <summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
<span>{title}</span> <span className="flex items-center gap-2">
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span> <span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
<span>{title}</span>
</span>
{actions && (
<span onClick={e => e.stopPropagation()}>
{actions}
</span>
)}
</summary> </summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div> <div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details> </details>

View File

@ -232,6 +232,60 @@ export type EmployeeDocumentDto = {
notes: string | null notes: string | null
} }
// ========== Satellite Create/Update Input Commands (S35 Plan B-WrapPlus1) ==========
// Mirror BE record Command field shape (EmployeeSatelliteFeatures.cs). UpdateInput
// reuse CreateInput shape (DRY) — handler set `id` separately at API call site.
export type CreateEmployeeWorkHistoryInput = {
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 CreateEmployeeEducationInput = {
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 CreateEmployeeFamilyRelationInput = {
fullName: string
relationship: number
birthYear: number | null
occupation: string | null
currentAddress: string | null
phone: string | null
}
export type CreateEmployeeSkillInput = {
kind: number
name: string
languageId: string | null
level: string | null
}
export type CreateEmployeeDocumentInput = {
documentType: number
fileName: string
filePath: string
fileSize: number
contentType: string
issueDate: string | null
expiryDate: string | null
notes: string | null
}
// ========== Detail (full + 5 satellite collection) ========== // ========== Detail (full + 5 satellite collection) ==========
export type EmployeeDetail = { export type EmployeeDetail = {