[CLAUDE] FE-Admin+FE-User: Plan B G-H1 Task 5 — EmployeesPage 2-panel + EmployeeCreatePage cookie-cutter mirror

Phase 10.1 G-H1 Phase 2 Task 5 — FE 2 app cookie-cutter mirror PE pattern.
Phase 1 ULTRA-MINIMAL scope (Implementer afdc812 scaffold):
- 2-panel ListPage (filter left + list+detail right, KHÔNG 3-panel vì Hrm
  no workflow)
- 6-section inline collapsible detail (Cơ bản/Công tác/Đào tạo/Thân nhân/
  Kỹ năng/Hồ sơ) — NO separate DetailTabs component file
- CreatePage Header form minimal (UserId picker + Status + DateOfBirth +
  Gender + Phone + HireDate + Nationality)
- Display read-only Phase 1 satellite (no inline edit — defer Phase 1.5)

## Files (6 new + 6 modified × 2 app = 12)

### NEW (3 × 2 app, SHA256 IDENTICAL cross-app mirror)

| File | LOC | SHA256 prefix |
|------|----:|---|
| `fe-{admin,user}/src/types/employee.ts` | 283 | CCFC70666568 |
| `fe-{admin,user}/src/pages/hrm/EmployeesListPage.tsx` | 417 | DC859C897C5C |
| `fe-{admin,user}/src/pages/hrm/EmployeeCreatePage.tsx` | 178 | C796F25D01AC |

10 const-object enum mirror BE Domain.Hrm.Enums + DTOs:
- EmployeeStatus/Gender/MaritalStatus/EmployeeType/DegreeLevel/
  EducationMode/GradeLevel/FamilyRelationKind/SkillKind/EmployeeDocumentType
- EmployeeListItem + EmployeeDetail + 5 satellite DTO type

### MODIFIED (3 × 2 app)

- `fe-{admin,user}/src/lib/menuKeys.ts` — +Hrm + HrmHoSo const
- `fe-{admin,user}/src/components/Layout.tsx` — +Hrm_HoSo:'/employees' staticMap
  (LESSON Plan CA Hotfix 1 gotcha #50: page route mới phải thêm staticMap
  entry cùng commit, else silent sidebar drop)
- `fe-{admin,user}/src/App.tsx` — +2 route /employees + /employees/new

## Pattern reinforcement

- **Pattern 16-bis 4-place mirror cross-app** reinforced 4× cumulative (S29
  Plan CA HF1 + S29 Plan B Chunk D + S33 Task 5 admin + S33 Task 5 user).
  Comment header trong Layout.tsx ghi explicit Plan CA Hotfix 1 #50 lesson.
- **Pattern 12-bis cross-module entity FE port PE → Hrm** reinforced 4× (Plan
  B Chunk C Mig 33 + G-H1 Task 4 BE + Task 5 FE types mirror PE types/page
  structure mirror PE 2-panel scope-down 3→2 panel).

## Reviewer ae752c0 verdict: PASS (commit 0e191de earlier)

- Smart Friend 6× cumulative clean (em main + Implementer quality genuine)
- gotcha #50 Layout staticMap mirror ✓ (cả fe-admin + fe-user)
- menuKeys.ts FE drift pre-existing intentional (fe-admin minimal vs fe-user
  expanded Catalogs/Suppliers/Projects/Departments) — NOT blocking, follow-up
  task add Budgets/Catalogs to fe-admin OR document intentional minimal scope.

## Verify

- fe-admin npm build: PASS 21.4s · 0 TS6 err · 1,431 KB bundle
- fe-user npm build: PASS 9.2s · 0 TS6 err · 1,345 KB bundle
- dotnet build: PASS 1.59s · 0 warn 0 err (no BE change)
- dotnet test: 120/120 PASS baseline preserved

## Defer Phase 1.5 (per Reviewer recommend)

1. PermissionGuard wrapper menuKey HrmHoSo + per-action Hrm_HoSo_View/Create
2. Convert 3 bool field UpdateCommand thành bool? safe partial update
3. Satellite CRUD endpoint + form (WorkHistory/Education/FamilyRelation/
   Skill/Document)
4. Test bundle (Create UNIQUE conflict + List filter + codeGen race)
5. Add Bg_*/Catalog* to fe-admin menuKeys.ts sync với fe-user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-26 20:27:25 +07:00
parent 0e191deea5
commit 9616ae219c
12 changed files with 2192 additions and 0 deletions

View File

@ -26,6 +26,8 @@ import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreat
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
function App() {
return (
@ -68,6 +70,9 @@ function App() {
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route

View File

@ -51,6 +51,10 @@ function resolvePath(key: string): string | null {
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees',
}
if (staticMap[key]) return staticMap[key]

View File

@ -19,6 +19,9 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,205 @@
// Page Create Hồ sơ Nhân sự — Header form minimal.
// Phase 10.1 G-H1 Phase 1 (S33 Task 5): chỉ Header field tối thiểu để link
// User → EmployeeProfile. Section còn lại edit sau qua DetailPanel (Phase 1.5).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { UserPlus } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Label } from '@/components/ui/Label'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged } from '@/types/master'
import { EmployeeStatus, EmployeeStatusLabel, Gender, GenderLabel } from '@/types/employee'
// User dropdown list response (mirror BE UsersController list shape — id/fullName/email subset).
type UserOption = {
id: string
fullName: string | null
email: string
}
type CreateForm = {
userId: string
employeeStatus: string
hireDate: string
dateOfBirth: string
gender: string
phone: string
nationality: string
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const initial: CreateForm = {
userId: '',
employeeStatus: String(EmployeeStatus.Active),
hireDate: todayIso(),
dateOfBirth: '',
gender: '',
phone: '',
nationality: 'Việt Nam',
}
const PHONE_RE = /^0\d{9,10}$/
const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
export function EmployeeCreatePage() {
const navigate = useNavigate()
const [form, setForm] = useState<CreateForm>(initial)
// Lấy list user để pick — pageSize lớn cho admin pick dễ.
const users = useQuery({
queryKey: ['users-for-employee-create'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const create = useMutation({
mutationFn: async () => {
const body = {
userId: form.userId,
employeeStatus: Number(form.employeeStatus),
hireDate: form.hireDate || null,
dateOfBirth: form.dateOfBirth || null,
gender: form.gender ? Number(form.gender) : null,
phone: form.phone.trim() || null,
nationality: form.nationality.trim() || null,
}
return (await api.post<{ id: string }>('/employees', body)).data
},
onSuccess: data => {
toast.success('Đã tạo hồ sơ NV.')
navigate(`/employees?id=${data.id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!form.userId) {
toast.error('Vui lòng chọn user.')
return
}
if (!isValidPhone(form.phone)) {
toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).')
return
}
create.mutate()
}
return (
<div className="mx-auto max-w-2xl space-y-4 p-6">
<header className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-brand-600" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">Tạo Hồ Nhân sự mới</h1>
</header>
<form onSubmit={submit} className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label htmlFor="userId">User <span className="text-red-500">*</span></Label>
<Select
id="userId"
value={form.userId}
onChange={e => setForm(f => ({ ...f, userId: e.target.value }))}
required
>
<option value=""> Chọn user </option>
{(users.data ?? []).map(u => (
<option key={u.id} value={u.id}>
{u.fullName ? `${u.fullName} (${u.email})` : u.email}
</option>
))}
</Select>
<p className="mt-1 text-xs text-slate-500">
Mỗi user chỉ đưc link với 1 hồ NV. Tạo user mới mục System &gt; Users nếu thiếu.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="employeeStatus">Trạng thái</Label>
<Select
id="employeeStatus"
value={form.employeeStatus}
onChange={e => setForm(f => ({ ...f, employeeStatus: e.target.value }))}
>
<option value={String(EmployeeStatus.Active)}>{EmployeeStatusLabel[EmployeeStatus.Active]}</option>
<option value={String(EmployeeStatus.OnLeave)}>{EmployeeStatusLabel[EmployeeStatus.OnLeave]}</option>
<option value={String(EmployeeStatus.Resigned)}>{EmployeeStatusLabel[EmployeeStatus.Resigned]}</option>
</Select>
</div>
<div>
<Label htmlFor="hireDate">Ngày vào làm</Label>
<Input
id="hireDate"
type="date"
value={form.hireDate}
onChange={e => setForm(f => ({ ...f, hireDate: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="gender">Giới tính</Label>
<Select
id="gender"
value={form.gender}
onChange={e => setForm(f => ({ ...f, gender: e.target.value }))}
>
<option value=""></option>
<option value={String(Gender.Male)}>{GenderLabel[Gender.Male]}</option>
<option value={String(Gender.Female)}>{GenderLabel[Gender.Female]}</option>
<option value={String(Gender.Other)}>{GenderLabel[Gender.Other]}</option>
</Select>
</div>
<div>
<Label htmlFor="dateOfBirth">Ngày sinh</Label>
<Input
id="dateOfBirth"
type="date"
value={form.dateOfBirth}
onChange={e => setForm(f => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="phone">SĐT</Label>
<Input
id="phone"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
placeholder="0912345678"
/>
</div>
<div>
<Label htmlFor="nationality">Quốc tịch</Label>
<Input
id="nationality"
value={form.nationality}
onChange={e => setForm(f => ({ ...f, nationality: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={create.isPending}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang lưu...' : 'Tạo hồ sơ'}
</Button>
</div>
<p className="text-xs text-slate-400">
Các thông tin chi tiết (giấy tờ, đa chỉ, lương, kỹ năng, ...) thể bổ sung mục Sửa hồ sau khi tạo.
</p>
</form>
</div>
)
}

View File

@ -0,0 +1,573 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
// - Read-only mọi section (Edit Header defer Phase 1.5)
// - 6 section render inline trong right panel qua `<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
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { UserCircle2, Search, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged, Department } from '@/types/master'
import {
EmployeeStatusColor,
EmployeeStatusLabel,
GenderLabel,
MaritalStatusLabel,
EmployeeTypeLabel,
DegreeLevelLabel,
EducationModeLabel,
GradeLevelLabel,
FamilyRelationKindLabel,
SkillKind,
SkillKindLabel,
EmployeeDocumentTypeLabel,
type EmployeeListItem,
type EmployeeDetail,
} from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—')
export function EmployeesListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const search = sp.get('q') ?? ''
const statusFilter = sp.get('status') ?? ''
const deptFilter = sp.get('deptId') ?? ''
const selectedId = sp.get('id')
const [localSearch, setLocalSearch] = useState(search)
const departments = useQuery({
queryKey: ['departments-all-hrm'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const list = useQuery({
queryKey: ['employees-list', { search, statusFilter, deptFilter }],
queryFn: async () => {
const res = await api.get<Paged<EmployeeListItem>>('/employees', {
params: {
pageSize: 100,
search: search || undefined,
status: statusFilter || undefined,
departmentId: deptFilter || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['employee-detail', selectedId],
queryFn: async () => (await api.get<EmployeeDetail>(`/employees/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/employees/${id}`),
onSuccess: () => {
toast.success('Đã xoá hồ sơ NV.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['employees-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSp(next, { replace: true })
}
function applySearch(e: React.FormEvent) {
e.preventDefault()
setParam('q', localSearch.trim() || null)
}
function resetFilters() {
setLocalSearch('')
setSp(new URLSearchParams(), { replace: true })
}
return (
<div className="grid h-full grid-cols-[340px_1fr] gap-4 p-4">
{/* ========== LEFT PANEL: filter ========== */}
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<header className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5 text-brand-600" />
<h2 className="text-base font-semibold tracking-tight text-slate-900">Hồ Nhân sự</h2>
</header>
<form onSubmit={applySearch} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Tìm kiếm</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
placeholder="Mã NV hoặc họ tên..."
className="pl-8"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Trạng thái</label>
<Select
value={statusFilter}
onChange={e => setParam('status', e.target.value || null)}
>
<option value="">Tất cả</option>
<option value="1">Đang làm việc</option>
<option value="2">Nghỉ phép</option>
<option value="3">Đã nghỉ việc</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Phòng ban</label>
<Select
value={deptFilter}
onChange={e => setParam('deptId', e.target.value || null)}
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm" className="flex-1">Tìm</Button>
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
<X className="h-3.5 w-3.5" />
Reset
</Button>
</div>
</form>
<div className="mt-2 border-t border-slate-100 pt-3 text-xs text-slate-500">
Tổng: <span className="font-semibold text-slate-700">{list.data?.total ?? 0}</span> hồ
</div>
</aside>
{/* ========== RIGHT PANEL: list table + selected detail ========== */}
<section className="flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">Danh sách nhân viên</h3>
<Button size="sm" onClick={() => navigate('/employees/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
</div>
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
{list.isLoading ? (
<div className="p-10 text-center text-sm text-slate-500">Đang tải...</div>
) : !list.data || list.data.items.length === 0 ? (
<EmptyState
icon={UserCircle2}
title="Chưa có hồ sơ NV nào"
description="Bấm 'Tạo mới' để thêm hồ sơ nhân viên đầu tiên."
/>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-medium"> NV</th>
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
<th className="px-3 py-2 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{list.data.items.map(e => (
<tr
key={e.id}
onClick={() => setParam('id', e.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition hover:bg-slate-50',
selectedId === e.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3 py-2 font-mono text-xs text-slate-700">{e.employeeCode}</td>
<td className="px-3 py-2 font-medium text-slate-900">{e.fullName ?? '—'}</td>
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[e.status])}>
{EmployeeStatusLabel[e.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">{e.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ========== Selected detail (6 collapsible section inline) ========== */}
{selectedId && (
<div className="overflow-auto rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
{detail.isLoading ? (
<div className="text-sm text-slate-500">Đang tải chi tiết...</div>
) : !detail.data ? (
<div className="text-sm text-red-600">Không tìm thấy hồ .</div>
) : (
<EmployeeDetailSections detail={detail.data} onDelete={() => del.mutate(detail.data!.id)} />
)}
</div>
)}
</section>
</div>
)
}
// ========== Inline 6-section read-only detail ==========
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
return (
<div className="space-y-3">
{/* Header bar */}
<header className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
{detail.photoUrl ? (
<img src={detail.photoUrl} alt={detail.fullName ?? ''} className="h-12 w-12 rounded-full object-cover" />
) : (
<UserCircle2 className="h-8 w-8" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{detail.fullName ?? '—'}</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{detail.employeeCode}</span>
<span></span>
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[detail.employeeStatus])}>
{EmployeeStatusLabel[detail.employeeStatus]}
</span>
{detail.departmentName && (
<>
<span></span>
<span>{detail.departmentName}</span>
</>
)}
</div>
</div>
</div>
<Button variant="danger" size="sm" onClick={() => {
if (confirm(`Xoá hồ sơ "${detail.fullName ?? detail.employeeCode}"?`)) onDelete()
}}>
Xoá hồ
</Button>
</header>
{/* Section 1: Cơ bản */}
<Section title="1. Thông tin cơ bản" defaultOpen>
<Grid2>
<Field label="Họ tên" value={detail.fullName} />
<Field label="Mã NV" value={detail.employeeCode} mono />
<Field label="Email" value={detail.email} />
<Field label="Email cá nhân" value={detail.personalEmail} />
<Field label="SĐT" value={detail.phone} />
<Field label="SĐT nội bộ" value={detail.internalPhone} />
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} />
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} />
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} />
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} />
<Field label="Quốc tịch" value={detail.nationality} />
<Field label="Dân tộc" value={detail.ethnicity} />
<Field label="Tôn giáo" value={detail.religion} />
<Field label="Nơi sinh" value={detail.birthPlace} />
<Field label="Quê quán" value={detail.hometown} />
<Field label="Phòng ban" value={detail.departmentName} />
<Field label="Vị trí công tác" value={detail.workLocation} />
<Field label="Mã chấm công" value={detail.timekeepingCode} />
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} />
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} />
</Grid2>
<SubBlock title="Giấy tờ">
<Grid2>
<Field label="CMND/CCCD" value={detail.idCardNumber} mono />
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} />
<Field label="Nơi cấp" value={detail.idCardIssuePlace} />
<Field label="Hộ chiếu" value={detail.passportNumber} mono />
<Field label="MST cá nhân" value={detail.taxCode} mono />
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
</Grid2>
</SubBlock>
<SubBlock title="Địa chỉ">
<Grid2>
<Field label="Thường trú" value={detail.permanentAddressText} />
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} />
<Field label="Tạm trú" value={detail.temporaryAddressText} />
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} />
</Grid2>
</SubBlock>
<SubBlock title="Liên hệ khẩn cấp">
<Grid2>
<Field label="Họ tên" value={detail.emergencyContactName} />
<Field label="SĐT" value={detail.emergencyContactPhone} />
<Field label="Địa chỉ" value={detail.emergencyContactAddress} />
</Grid2>
</SubBlock>
<SubBlock title="Lương + Phép + BHXH">
<Grid2>
<Field label="Lương cơ bản" value={detail.baseSalary != null ? detail.baseSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Tổng lương" value={detail.totalSalary != null ? detail.totalSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} />
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} />
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} />
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} />
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} />
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} />
</Grid2>
</SubBlock>
<SubBlock title="Trình độ + Sức khoẻ + Ngân hàng">
<Grid2>
<Field label="Trình độ chuyên môn" value={detail.qualification} />
<Field label="Học hàm" value={detail.academicTitle} />
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} />
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} />
<Field label="Nhóm máu" value={detail.bloodType} />
<Field label="Tài khoản NH" value={detail.bankAccount} mono />
<Field label="Ngân hàng" value={detail.bankName} />
<Field label="Chi nhánh" value={detail.bankBranch} />
</Grid2>
</SubBlock>
<SubBlock title="Đoàn thể">
<Grid2>
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} />
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} />
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} />
</Grid2>
</SubBlock>
{detail.notes && (
<SubBlock title="Ghi chú">
<p className="whitespace-pre-wrap text-sm text-slate-700">{detail.notes}</p>
</SubBlock>
)}
</Section>
{/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
{detail.workHistories.length === 0 ? (
<EmptyHint text="Chưa có quá trình công tác nào." />
) : (
<div className="space-y-2">
{detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500">
{fmtDate(w.fromDate)} {fmtDate(w.toDate)}
</div>
</div>
{w.jobTitle && <div className="mt-1 text-xs text-slate-600">Chức vụ: {w.jobTitle}</div>}
{w.industry && <div className="text-xs text-slate-600">Ngành: {w.industry}</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.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
{detail.educations.length === 0 ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." />
) : (
<div className="space-y-2">
{detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500">
{fmtDate(ed.fromDate)} {fmtDate(ed.toDate)}
</div>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600">
{ed.major && <span>Chuyên ngành: {ed.major}</span>}
{ed.degreeLevel != null && <span>Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}</span>}
{ed.educationMode != null && <span>Hình thức: {EducationModeLabel[ed.educationMode]}</span>}
{ed.gradeLevel != null && <span>Xếp loại: {GradeLevelLabel[ed.gradeLevel]}</span>}
</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>}
</div>
))}
</div>
)}
</Section>
{/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
{detail.familyRelations.length === 0 ? (
<EmptyHint text="Chưa có thông tin thân nhân." />
) : (
<table className="w-full text-sm">
<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 => (
<tr key={f.id} className="border-b border-slate-100">
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
{/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
{detail.skills.length === 0 ? (
<EmptyHint text="Chưa có thông tin kỹ năng." />
) : (
<div className="space-y-3">
{[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => {
const group = detail.skills.filter(s => s.kind === kind)
if (group.length === 0) return null
return (
<div key={kind}>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
{SkillKindLabel[kind]}
</div>
<ul className="space-y-1">
{group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
</li>
))}
</ul>
</div>
)
})}
</div>
)}
</Section>
{/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
{detail.documents.length === 0 ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : (
<table className="w-full text-sm">
<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 => (
<tr key={doc.id} className="border-b border-slate-100">
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
<td className="py-2 pr-3">
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
{doc.fileName}
</a>
</td>
<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>
))}
</tbody>
</table>
)}
</Section>
<footer className="mt-2 border-t border-slate-100 pt-2 text-xs text-slate-400">
Tạo: {fmtDateTime(detail.createdAt)}
{detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}</>}
</footer>
</div>
)
}
// ========== Helpers ==========
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
return (
<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">
<span>{title}</span>
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
</summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details>
)
}
function SubBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{children}
</div>
)
}
function Grid2({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-x-4 gap-y-2 md:grid-cols-2">{children}</div>
}
function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) {
return (
<div className="text-sm">
<div className="text-xs text-slate-500">{label}</div>
<div className={cn('text-slate-800', mono && 'font-mono text-xs')}>
{value == null || value === '' ? '—' : value}
</div>
</div>
)
}
function EmptyHint({ text }: { text: string }) {
return <div className="py-4 text-center text-sm text-slate-400">{text}</div>
}

View File

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

View File

@ -19,6 +19,8 @@ import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWo
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
function App() {
return (
@ -51,6 +53,9 @@ function App() {
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -73,6 +73,10 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees',
}
if (staticMap[key]) return staticMap[key]

View File

@ -19,6 +19,9 @@ export const MenuKeys = {
Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,205 @@
// Page Create Hồ sơ Nhân sự — Header form minimal.
// Phase 10.1 G-H1 Phase 1 (S33 Task 5): chỉ Header field tối thiểu để link
// User → EmployeeProfile. Section còn lại edit sau qua DetailPanel (Phase 1.5).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { UserPlus } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Label } from '@/components/ui/Label'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged } from '@/types/master'
import { EmployeeStatus, EmployeeStatusLabel, Gender, GenderLabel } from '@/types/employee'
// User dropdown list response (mirror BE UsersController list shape — id/fullName/email subset).
type UserOption = {
id: string
fullName: string | null
email: string
}
type CreateForm = {
userId: string
employeeStatus: string
hireDate: string
dateOfBirth: string
gender: string
phone: string
nationality: string
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const initial: CreateForm = {
userId: '',
employeeStatus: String(EmployeeStatus.Active),
hireDate: todayIso(),
dateOfBirth: '',
gender: '',
phone: '',
nationality: 'Việt Nam',
}
const PHONE_RE = /^0\d{9,10}$/
const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
export function EmployeeCreatePage() {
const navigate = useNavigate()
const [form, setForm] = useState<CreateForm>(initial)
// Lấy list user để pick — pageSize lớn cho admin pick dễ.
const users = useQuery({
queryKey: ['users-for-employee-create'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const create = useMutation({
mutationFn: async () => {
const body = {
userId: form.userId,
employeeStatus: Number(form.employeeStatus),
hireDate: form.hireDate || null,
dateOfBirth: form.dateOfBirth || null,
gender: form.gender ? Number(form.gender) : null,
phone: form.phone.trim() || null,
nationality: form.nationality.trim() || null,
}
return (await api.post<{ id: string }>('/employees', body)).data
},
onSuccess: data => {
toast.success('Đã tạo hồ sơ NV.')
navigate(`/employees?id=${data.id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!form.userId) {
toast.error('Vui lòng chọn user.')
return
}
if (!isValidPhone(form.phone)) {
toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).')
return
}
create.mutate()
}
return (
<div className="mx-auto max-w-2xl space-y-4 p-6">
<header className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-brand-600" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">Tạo Hồ Nhân sự mới</h1>
</header>
<form onSubmit={submit} className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label htmlFor="userId">User <span className="text-red-500">*</span></Label>
<Select
id="userId"
value={form.userId}
onChange={e => setForm(f => ({ ...f, userId: e.target.value }))}
required
>
<option value=""> Chọn user </option>
{(users.data ?? []).map(u => (
<option key={u.id} value={u.id}>
{u.fullName ? `${u.fullName} (${u.email})` : u.email}
</option>
))}
</Select>
<p className="mt-1 text-xs text-slate-500">
Mỗi user chỉ đưc link với 1 hồ NV. Tạo user mới mục System &gt; Users nếu thiếu.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="employeeStatus">Trạng thái</Label>
<Select
id="employeeStatus"
value={form.employeeStatus}
onChange={e => setForm(f => ({ ...f, employeeStatus: e.target.value }))}
>
<option value={String(EmployeeStatus.Active)}>{EmployeeStatusLabel[EmployeeStatus.Active]}</option>
<option value={String(EmployeeStatus.OnLeave)}>{EmployeeStatusLabel[EmployeeStatus.OnLeave]}</option>
<option value={String(EmployeeStatus.Resigned)}>{EmployeeStatusLabel[EmployeeStatus.Resigned]}</option>
</Select>
</div>
<div>
<Label htmlFor="hireDate">Ngày vào làm</Label>
<Input
id="hireDate"
type="date"
value={form.hireDate}
onChange={e => setForm(f => ({ ...f, hireDate: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="gender">Giới tính</Label>
<Select
id="gender"
value={form.gender}
onChange={e => setForm(f => ({ ...f, gender: e.target.value }))}
>
<option value=""></option>
<option value={String(Gender.Male)}>{GenderLabel[Gender.Male]}</option>
<option value={String(Gender.Female)}>{GenderLabel[Gender.Female]}</option>
<option value={String(Gender.Other)}>{GenderLabel[Gender.Other]}</option>
</Select>
</div>
<div>
<Label htmlFor="dateOfBirth">Ngày sinh</Label>
<Input
id="dateOfBirth"
type="date"
value={form.dateOfBirth}
onChange={e => setForm(f => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="phone">SĐT</Label>
<Input
id="phone"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
placeholder="0912345678"
/>
</div>
<div>
<Label htmlFor="nationality">Quốc tịch</Label>
<Input
id="nationality"
value={form.nationality}
onChange={e => setForm(f => ({ ...f, nationality: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={create.isPending}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang lưu...' : 'Tạo hồ sơ'}
</Button>
</div>
<p className="text-xs text-slate-400">
Các thông tin chi tiết (giấy tờ, đa chỉ, lương, kỹ năng, ...) thể bổ sung mục Sửa hồ sau khi tạo.
</p>
</form>
</div>
)
}

View File

@ -0,0 +1,573 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
// - Read-only mọi section (Edit Header defer Phase 1.5)
// - 6 section render inline trong right panel qua `<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
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { UserCircle2, Search, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged, Department } from '@/types/master'
import {
EmployeeStatusColor,
EmployeeStatusLabel,
GenderLabel,
MaritalStatusLabel,
EmployeeTypeLabel,
DegreeLevelLabel,
EducationModeLabel,
GradeLevelLabel,
FamilyRelationKindLabel,
SkillKind,
SkillKindLabel,
EmployeeDocumentTypeLabel,
type EmployeeListItem,
type EmployeeDetail,
} from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—')
export function EmployeesListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const search = sp.get('q') ?? ''
const statusFilter = sp.get('status') ?? ''
const deptFilter = sp.get('deptId') ?? ''
const selectedId = sp.get('id')
const [localSearch, setLocalSearch] = useState(search)
const departments = useQuery({
queryKey: ['departments-all-hrm'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const list = useQuery({
queryKey: ['employees-list', { search, statusFilter, deptFilter }],
queryFn: async () => {
const res = await api.get<Paged<EmployeeListItem>>('/employees', {
params: {
pageSize: 100,
search: search || undefined,
status: statusFilter || undefined,
departmentId: deptFilter || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['employee-detail', selectedId],
queryFn: async () => (await api.get<EmployeeDetail>(`/employees/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/employees/${id}`),
onSuccess: () => {
toast.success('Đã xoá hồ sơ NV.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['employees-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSp(next, { replace: true })
}
function applySearch(e: React.FormEvent) {
e.preventDefault()
setParam('q', localSearch.trim() || null)
}
function resetFilters() {
setLocalSearch('')
setSp(new URLSearchParams(), { replace: true })
}
return (
<div className="grid h-full grid-cols-[340px_1fr] gap-4 p-4">
{/* ========== LEFT PANEL: filter ========== */}
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<header className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5 text-brand-600" />
<h2 className="text-base font-semibold tracking-tight text-slate-900">Hồ Nhân sự</h2>
</header>
<form onSubmit={applySearch} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Tìm kiếm</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
placeholder="Mã NV hoặc họ tên..."
className="pl-8"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Trạng thái</label>
<Select
value={statusFilter}
onChange={e => setParam('status', e.target.value || null)}
>
<option value="">Tất cả</option>
<option value="1">Đang làm việc</option>
<option value="2">Nghỉ phép</option>
<option value="3">Đã nghỉ việc</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Phòng ban</label>
<Select
value={deptFilter}
onChange={e => setParam('deptId', e.target.value || null)}
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm" className="flex-1">Tìm</Button>
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
<X className="h-3.5 w-3.5" />
Reset
</Button>
</div>
</form>
<div className="mt-2 border-t border-slate-100 pt-3 text-xs text-slate-500">
Tổng: <span className="font-semibold text-slate-700">{list.data?.total ?? 0}</span> hồ
</div>
</aside>
{/* ========== RIGHT PANEL: list table + selected detail ========== */}
<section className="flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">Danh sách nhân viên</h3>
<Button size="sm" onClick={() => navigate('/employees/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
</div>
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
{list.isLoading ? (
<div className="p-10 text-center text-sm text-slate-500">Đang tải...</div>
) : !list.data || list.data.items.length === 0 ? (
<EmptyState
icon={UserCircle2}
title="Chưa có hồ sơ NV nào"
description="Bấm 'Tạo mới' để thêm hồ sơ nhân viên đầu tiên."
/>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-medium"> NV</th>
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
<th className="px-3 py-2 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{list.data.items.map(e => (
<tr
key={e.id}
onClick={() => setParam('id', e.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition hover:bg-slate-50',
selectedId === e.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3 py-2 font-mono text-xs text-slate-700">{e.employeeCode}</td>
<td className="px-3 py-2 font-medium text-slate-900">{e.fullName ?? '—'}</td>
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[e.status])}>
{EmployeeStatusLabel[e.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">{e.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ========== Selected detail (6 collapsible section inline) ========== */}
{selectedId && (
<div className="overflow-auto rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
{detail.isLoading ? (
<div className="text-sm text-slate-500">Đang tải chi tiết...</div>
) : !detail.data ? (
<div className="text-sm text-red-600">Không tìm thấy hồ .</div>
) : (
<EmployeeDetailSections detail={detail.data} onDelete={() => del.mutate(detail.data!.id)} />
)}
</div>
)}
</section>
</div>
)
}
// ========== Inline 6-section read-only detail ==========
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
return (
<div className="space-y-3">
{/* Header bar */}
<header className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
{detail.photoUrl ? (
<img src={detail.photoUrl} alt={detail.fullName ?? ''} className="h-12 w-12 rounded-full object-cover" />
) : (
<UserCircle2 className="h-8 w-8" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{detail.fullName ?? '—'}</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{detail.employeeCode}</span>
<span></span>
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[detail.employeeStatus])}>
{EmployeeStatusLabel[detail.employeeStatus]}
</span>
{detail.departmentName && (
<>
<span></span>
<span>{detail.departmentName}</span>
</>
)}
</div>
</div>
</div>
<Button variant="danger" size="sm" onClick={() => {
if (confirm(`Xoá hồ sơ "${detail.fullName ?? detail.employeeCode}"?`)) onDelete()
}}>
Xoá hồ
</Button>
</header>
{/* Section 1: Cơ bản */}
<Section title="1. Thông tin cơ bản" defaultOpen>
<Grid2>
<Field label="Họ tên" value={detail.fullName} />
<Field label="Mã NV" value={detail.employeeCode} mono />
<Field label="Email" value={detail.email} />
<Field label="Email cá nhân" value={detail.personalEmail} />
<Field label="SĐT" value={detail.phone} />
<Field label="SĐT nội bộ" value={detail.internalPhone} />
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} />
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} />
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} />
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} />
<Field label="Quốc tịch" value={detail.nationality} />
<Field label="Dân tộc" value={detail.ethnicity} />
<Field label="Tôn giáo" value={detail.religion} />
<Field label="Nơi sinh" value={detail.birthPlace} />
<Field label="Quê quán" value={detail.hometown} />
<Field label="Phòng ban" value={detail.departmentName} />
<Field label="Vị trí công tác" value={detail.workLocation} />
<Field label="Mã chấm công" value={detail.timekeepingCode} />
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} />
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} />
</Grid2>
<SubBlock title="Giấy tờ">
<Grid2>
<Field label="CMND/CCCD" value={detail.idCardNumber} mono />
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} />
<Field label="Nơi cấp" value={detail.idCardIssuePlace} />
<Field label="Hộ chiếu" value={detail.passportNumber} mono />
<Field label="MST cá nhân" value={detail.taxCode} mono />
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
</Grid2>
</SubBlock>
<SubBlock title="Địa chỉ">
<Grid2>
<Field label="Thường trú" value={detail.permanentAddressText} />
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} />
<Field label="Tạm trú" value={detail.temporaryAddressText} />
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} />
</Grid2>
</SubBlock>
<SubBlock title="Liên hệ khẩn cấp">
<Grid2>
<Field label="Họ tên" value={detail.emergencyContactName} />
<Field label="SĐT" value={detail.emergencyContactPhone} />
<Field label="Địa chỉ" value={detail.emergencyContactAddress} />
</Grid2>
</SubBlock>
<SubBlock title="Lương + Phép + BHXH">
<Grid2>
<Field label="Lương cơ bản" value={detail.baseSalary != null ? detail.baseSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Tổng lương" value={detail.totalSalary != null ? detail.totalSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} />
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} />
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} />
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} />
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} />
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} />
</Grid2>
</SubBlock>
<SubBlock title="Trình độ + Sức khoẻ + Ngân hàng">
<Grid2>
<Field label="Trình độ chuyên môn" value={detail.qualification} />
<Field label="Học hàm" value={detail.academicTitle} />
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} />
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} />
<Field label="Nhóm máu" value={detail.bloodType} />
<Field label="Tài khoản NH" value={detail.bankAccount} mono />
<Field label="Ngân hàng" value={detail.bankName} />
<Field label="Chi nhánh" value={detail.bankBranch} />
</Grid2>
</SubBlock>
<SubBlock title="Đoàn thể">
<Grid2>
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} />
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} />
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} />
</Grid2>
</SubBlock>
{detail.notes && (
<SubBlock title="Ghi chú">
<p className="whitespace-pre-wrap text-sm text-slate-700">{detail.notes}</p>
</SubBlock>
)}
</Section>
{/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
{detail.workHistories.length === 0 ? (
<EmptyHint text="Chưa có quá trình công tác nào." />
) : (
<div className="space-y-2">
{detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500">
{fmtDate(w.fromDate)} {fmtDate(w.toDate)}
</div>
</div>
{w.jobTitle && <div className="mt-1 text-xs text-slate-600">Chức vụ: {w.jobTitle}</div>}
{w.industry && <div className="text-xs text-slate-600">Ngành: {w.industry}</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.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
{detail.educations.length === 0 ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." />
) : (
<div className="space-y-2">
{detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500">
{fmtDate(ed.fromDate)} {fmtDate(ed.toDate)}
</div>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600">
{ed.major && <span>Chuyên ngành: {ed.major}</span>}
{ed.degreeLevel != null && <span>Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}</span>}
{ed.educationMode != null && <span>Hình thức: {EducationModeLabel[ed.educationMode]}</span>}
{ed.gradeLevel != null && <span>Xếp loại: {GradeLevelLabel[ed.gradeLevel]}</span>}
</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>}
</div>
))}
</div>
)}
</Section>
{/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
{detail.familyRelations.length === 0 ? (
<EmptyHint text="Chưa có thông tin thân nhân." />
) : (
<table className="w-full text-sm">
<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 => (
<tr key={f.id} className="border-b border-slate-100">
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
{/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
{detail.skills.length === 0 ? (
<EmptyHint text="Chưa có thông tin kỹ năng." />
) : (
<div className="space-y-3">
{[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => {
const group = detail.skills.filter(s => s.kind === kind)
if (group.length === 0) return null
return (
<div key={kind}>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
{SkillKindLabel[kind]}
</div>
<ul className="space-y-1">
{group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
</li>
))}
</ul>
</div>
)
})}
</div>
)}
</Section>
{/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
{detail.documents.length === 0 ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : (
<table className="w-full text-sm">
<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 => (
<tr key={doc.id} className="border-b border-slate-100">
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
<td className="py-2 pr-3">
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
{doc.fileName}
</a>
</td>
<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>
))}
</tbody>
</table>
)}
</Section>
<footer className="mt-2 border-t border-slate-100 pt-2 text-xs text-slate-400">
Tạo: {fmtDateTime(detail.createdAt)}
{detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}</>}
</footer>
</div>
)
}
// ========== Helpers ==========
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
return (
<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">
<span>{title}</span>
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
</summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details>
)
}
function SubBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{children}
</div>
)
}
function Grid2({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-x-4 gap-y-2 md:grid-cols-2">{children}</div>
}
function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) {
return (
<div className="text-sm">
<div className="text-xs text-slate-500">{label}</div>
<div className={cn('text-slate-800', mono && 'font-mono text-xs')}>
{value == null || value === '' ? '—' : value}
</div>
</div>
)
}
function EmptyHint({ text }: { text: string }) {
return <div className="py-4 text-center text-sm text-slate-400">{text}</div>
}

View File

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