[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:
@ -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="*"
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
205
fe-user/src/pages/hrm/EmployeeCreatePage.tsx
Normal file
205
fe-user/src/pages/hrm/EmployeeCreatePage.tsx
Normal 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ồ sơ 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ồ sơ NV. Tạo user mới ở mục System > 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, ...) có thể bổ sung ở mục Sửa hồ sơ sau khi tạo.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
573
fe-user/src/pages/hrm/EmployeesListPage.tsx
Normal file
573
fe-user/src/pages/hrm/EmployeesListPage.tsx
Normal 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ồ sơ 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ồ sơ
|
||||
</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">Mã 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ồ sơ.</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ồ sơ
|
||||
</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">Lý 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">Mã: {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>
|
||||
}
|
||||
306
fe-user/src/types/employee.ts
Normal file
306
fe-user/src/types/employee.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user