[CLAUDE] FE-Admin+FE-User: S35 Plan G-H2 Task 4 — HrmConfigsPage declarative 4 catalog × 2 app
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m37s

Pattern 12-bis × 16-bis cookie-cutter cumulative 9× (Smart Friend Implementer catch).
Single page declarative KIND_CONFIG Record + URL `:kind` param + 4 sub-tab mirror
Master/Catalogs/CatalogsPage.tsx 321 LOC pattern. 4 catalog HRM (LeaveTypes/Holidays/
Shifts/OtPolicies) wire BE 16 endpoint /api/hrm-configs/{kind} từ commit `909655c`.

## Scope (Implementer Case 2 ~25K spawn)
- types/hrm-config.ts (NEW 98 LOC × 2 app SHA256 IDENTICAL `228917e5fac2cdc6`)
- HrmConfigsPage.tsx (NEW ~470 LOC × 2 app SHA256 IDENTICAL `6378fbc71ff90260`)
- App.tsx +3 LOC × 2 app (route + Navigate redirect default `/hrm/configs` → `leave-types`)
- Layout.tsx staticMap +6 LOC × 2 app — Pattern 16-bis 4-place enforcement
  (em main spec line 24 GAP — Smart Friend Implementer caught + fixed proactive)

## 4 kind FieldDef declarative
- leave-types: code/name/daysPerYear/isPaid/requiresAttachment/description
- holidays: year/date/name/isRecurring/isPaid/description
- shifts: code/name/startTime/endTime/breakMinutes/workDays(multiselect-weekday)/description
- ot-policies: code/name/multiplier×3/maxHours×3/description

## Pattern 16-bis 4-place mirror Pattern 16-bis 6× cumulative
- types/hrm-config.ts (place 1)
- pages/hrm/HrmConfigsPage.tsx (place 2)
- App.tsx routes (place 3)
- Layout.tsx staticMap (place 4 — em main MISS, Implementer caught via gotcha #50 prior knowledge)

## Verify
- npm build × 2 PASS (fe-admin 14.33s + fe-user 744ms, 0 TS error)
- SHA256 IDENTICAL × 2 NEW pair (types + page)
- Reviewer pre-commit PASS Cat A-D clean (5K token tight scope no truncation)
- gotcha #50 silent sidebar drop prevention — Pattern 16-bis discipline reinforced
- Smart Friend 9× clean cumulative S22+S25+S29×2+S33×2+S35×3

## Multi-agent ROI S35 chunk 3 ~30K
- Implementer Case 2 declarative single-page mega + spec gap catch
- Reviewer tight 5K verdict Cat A-D PASS

## State delta S35 cumulative
- 0 mig add (Mig 35 schema S34)
- 0 BE endpoint add (16 endpoint commit `909655c`)
- +1 FE page (HrmConfigsPage) × 2 app routes
- Pattern 12-bis cumulative 3× + Pattern 16-bis 6× + Pattern 12-ter 6× (FE forms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 10:01:43 +07:00
parent 909655c40d
commit 021674a66a
8 changed files with 1388 additions and 0 deletions

View File

@ -28,6 +28,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage' import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
function App() { function App() {
@ -74,6 +75,9 @@ function App() {
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} /> <Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} /> <Route path="/employees/new" element={<EmployeeCreatePage />} />
{/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */}
<Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} />
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
<Route path="/directory" element={<InternalDirectoryPage />} /> <Route path="/directory" element={<InternalDirectoryPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />

View File

@ -55,6 +55,12 @@ function resolvePath(key: string): string | null {
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới // 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. // — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees', Hrm_HoSo: '/employees',
// [Phase 10.2 G-H2 S35 2026-05-28] Cấu hình HRM 4 catalog leaf (Mig 35).
// Pattern 16-bis 4-place mirror (staticMap = 4th place, dễ miss nhất).
Hrm_Config_LeaveTypes: '/hrm/configs/leave-types',
Hrm_Config_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ. // [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory', Off_DanhBa: '/directory',

View File

@ -0,0 +1,580 @@
// HRM Configs CRUD — 1 page handle 4 kind: leave-types / holidays / shifts / ot-policies.
// URL `/hrm/configs/:kind` driven, mỗi kind có fields config riêng + columns table riêng.
// Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh.
// Pattern 12-bis × 16-bis cookie-cutter mirror Master/Catalogs/CatalogsPage.tsx
// (9th cumulative S35 — declarative KIND_CONFIG Record + URL `:kind` param).
import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { HrmConfigKind } from '@/types/hrm-config'
type Kind = HrmConfigKind
type FieldType = 'text' | 'textarea' | 'checkbox' | 'number' | 'date' | 'time' | 'multiselect-weekday'
type FieldDef = {
key: string
label: string
type: FieldType
required?: boolean
placeholder?: string
step?: number // for number type
}
const WEEKDAY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'Mon', label: 'T2' },
{ value: 'Tue', label: 'T3' },
{ value: 'Wed', label: 'T4' },
{ value: 'Thu', label: 'T5' },
{ value: 'Fri', label: 'T6' },
{ value: 'Sat', label: 'T7' },
{ value: 'Sun', label: 'CN' },
]
const KIND_CONFIG: Record<Kind, {
label: string
description: string
icon: ComponentType<{ className?: string }>
fields: FieldDef[]
columns: string[]
}> = {
'leave-types': {
label: 'Loại phép',
description: 'Danh mục loại phép (phép năm, ốm, không lương...) — dùng khi nhân viên đăng ký nghỉ.',
icon: Plane,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'ANNUAL, SICK, UNPAID...' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Phép năm, Phép ốm...' },
{ key: 'daysPerYear', label: 'Số ngày/năm', type: 'number', step: 0.5, placeholder: '12, 30...' },
{ key: 'isPaid', label: 'Có lương', type: 'checkbox' },
{ key: 'requiresAttachment', label: 'Yêu cầu giấy tờ đính kèm', type: 'checkbox' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Ngày/năm', 'Có lương', 'Đính kèm', 'Trạng thái'],
},
'holidays': {
label: 'Ngày lễ',
description: 'Lịch nghỉ lễ theo năm — Tết Dương lịch, Tết Âm, 30/4, 1/5, 2/9...',
icon: Calendar,
fields: [
{ key: 'year', label: 'Năm *', type: 'number', required: true, placeholder: '2026' },
{ key: 'date', label: 'Ngày *', type: 'date', required: true },
{ key: 'name', label: 'Tên ngày lễ *', type: 'text', required: true, placeholder: 'Tết Dương lịch, 30/4...' },
{ key: 'isRecurring', label: 'Lặp lại hàng năm (vd 1/1)', type: 'checkbox' },
{ key: 'isPaid', label: 'Có lương', type: 'checkbox' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Năm', 'Ngày', 'Tên', 'Lặp lại', 'Có lương', 'Trạng thái'],
},
'shifts': {
label: 'Ca làm việc',
description: 'Cấu hình ca làm việc (hành chính, ca sáng, ca chiều...) — dùng cho timesheet + chấm công.',
icon: Clock,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'HC, CA1, CA2...' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Hành chính, Ca sáng...' },
{ key: 'startTime', label: 'Giờ bắt đầu *', type: 'time', required: true },
{ key: 'endTime', label: 'Giờ kết thúc *', type: 'time', required: true },
{ key: 'breakMinutes', label: 'Nghỉ giữa ca (phút)', type: 'number', placeholder: '60' },
{ key: 'workDays', label: 'Ngày làm việc trong tuần *', type: 'multiselect-weekday', required: true },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Thời gian', 'Nghỉ', 'Ngày làm', 'Trạng thái'],
},
'ot-policies': {
label: 'Chính sách OT',
description: 'Cấu hình hệ số OT + giới hạn giờ/ngày, giờ/tháng, giờ/năm.',
icon: Repeat,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'STANDARD' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Chính sách OT chuẩn' },
{ key: 'multiplierWeekday', label: 'Hệ số ngày thường *', type: 'number', required: true, step: 0.1, placeholder: '1.5' },
{ key: 'multiplierWeekend', label: 'Hệ số cuối tuần *', type: 'number', required: true, step: 0.1, placeholder: '2.0' },
{ key: 'multiplierHoliday', label: 'Hệ số ngày lễ *', type: 'number', required: true, step: 0.1, placeholder: '3.0' },
{ key: 'maxHoursPerDay', label: 'Max giờ/ngày *', type: 'number', required: true, placeholder: '4' },
{ key: 'maxHoursPerMonth', label: 'Max giờ/tháng *', type: 'number', required: true, placeholder: '40' },
{ key: 'maxHoursPerYear', label: 'Max giờ/năm *', type: 'number', required: true, placeholder: '200' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Hệ số WD/WE/HL', 'Max h/year', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies']
type ConfigRow = Record<string, unknown> & { id: string; isActive?: boolean }
export function HrmConfigsPage() {
const navigate = useNavigate()
const params = useParams<{ kind?: string }>()
const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'leave-types') as Kind
const config = KIND_CONFIG[kind]
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Record<string, unknown>>({})
const isEdit = !!form.id
const list = useQuery({
queryKey: ['hrm-configs', kind, search],
queryFn: async () => (await api.get<ConfigRow[]>(`/hrm-configs/${kind}`, { params: { q: search || undefined } })).data,
})
const save = useMutation({
mutationFn: async () => {
const body = buildBody(kind, form, isEdit)
if (isEdit) await api.put(`/hrm-configs/${kind}/${form.id}`, { id: form.id, ...body })
else await api.post(`/hrm-configs/${kind}`, body)
},
onSuccess: () => {
toast.success(isEdit ? 'Đã lưu' : 'Đã thêm')
qc.invalidateQueries({ queryKey: ['hrm-configs', kind] })
setOpen(false)
setForm({})
},
onError: err => toast.error(getErrorMessage(err)),
})
const remove = useMutation({
mutationFn: async (id: string) => { await api.delete(`/hrm-configs/${kind}/${id}`) },
onSuccess: () => {
toast.success('Đã xóa')
qc.invalidateQueries({ queryKey: ['hrm-configs', kind] })
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreate() {
const init: Record<string, unknown> = {}
config.fields.forEach(f => {
if (f.type === 'checkbox') init[f.key] = false
else if (f.type === 'number') init[f.key] = ''
else init[f.key] = ''
})
// Per-kind smart defaults
if (kind === 'leave-types') init.isPaid = true
if (kind === 'holidays') {
init.year = new Date().getFullYear()
init.isPaid = true
}
if (kind === 'shifts') {
init.startTime = '08:00'
init.endTime = '17:00'
init.breakMinutes = 60
init.workDays = 'Mon,Tue,Wed,Thu,Fri'
}
if (kind === 'ot-policies') {
init.multiplierWeekday = 1.5
init.multiplierWeekend = 2.0
init.multiplierHoliday = 3.0
init.maxHoursPerDay = 4
init.maxHoursPerMonth = 40
init.maxHoursPerYear = 200
}
setForm(init)
setOpen(true)
}
function openEdit(row: ConfigRow) {
const init: Record<string, unknown> = { id: row.id, isActive: row.isActive ?? true }
config.fields.forEach(f => {
const raw = row[f.key]
if (f.type === 'checkbox') init[f.key] = !!raw
else if (f.type === 'time' && typeof raw === 'string') init[f.key] = raw.slice(0, 5) // 'HH:mm:ss' → 'HH:mm'
else init[f.key] = raw ?? (f.type === 'number' ? '' : '')
})
setForm(init)
setOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
Cấu hình HRM
</span>
}
description="Catalog cấu hình dùng chung cho module Nhân sự — Loại phép / Ngày lễ / Ca làm việc / Chính sách OT."
/>
{/* Sub-tabs cho 4 kind */}
<div className="mb-4 flex flex-wrap gap-1 border-b border-slate-200">
{KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon
const active = k === kind
return (
<button
key={k}
onClick={() => navigate(`/hrm/configs/${k}`)}
className={cn(
'-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition',
active
? 'border-brand-600 font-semibold text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
<KIcon className="h-4 w-4" />
{KIND_CONFIG[k].label}
</button>
)
})}
</div>
<p className="mb-3 text-xs text-slate-500">{config.description}</p>
<div className="mb-3 flex gap-2">
<div className="relative max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={kind === 'holidays' ? 'Tìm theo tên ngày lễ…' : 'Tìm theo mã / tên…'}
className="pl-8"
/>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Thêm {config.label.toLowerCase()}
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
{config.columns.map(c => (
<th key={c} className="px-3 py-2 text-left">{c}</th>
))}
<th className="w-20 px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.isLoading && (
<tr><td colSpan={config.columns.length + 1} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)}
{!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={config.columns.length + 1} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr>
)}
{list.data?.map(row => (
<RowRenderer
key={row.id}
kind={kind}
row={row}
onEdit={() => openEdit(row)}
onRemove={() => {
const label = (row.name as string) ?? (row.code as string) ?? row.id
if (confirm(`Xóa "${label}"?`)) remove.mutate(row.id)
}}
removing={remove.isPending}
/>
))}
</tbody>
</table>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
<Button
onClick={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
disabled={save.isPending}
>
{save.isPending ? 'Đang lưu…' : (isEdit ? 'Lưu' : 'Thêm')}
</Button>
</>
}
>
<form
onSubmit={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
className="grid grid-cols-2 gap-3"
>
{config.fields.map(f => (
<div
key={f.key}
className={cn(
'space-y-1',
(f.type === 'textarea' || f.type === 'multiselect-weekday') && 'col-span-2',
)}
>
<Label>{f.label}</Label>
{renderField(f, form[f.key], v => setForm(s => ({ ...s, [f.key]: v })))}
</div>
))}
{isEdit && (
<div className="col-span-2 space-y-1">
<Label>Trạng thái</Label>
<div className="flex items-center gap-2 pt-1">
<input
type="checkbox"
checked={!!form.isActive}
onChange={e => setForm(s => ({ ...s, isActive: e.target.checked }))}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">Đang dùng</span>
</div>
</div>
)}
</form>
</Dialog>
</div>
)
}
// ========== Helper components / functions ==========
function RowRenderer({
kind, row, onEdit, onRemove, removing,
}: {
kind: Kind
row: ConfigRow
onEdit: () => void
onRemove: () => void
removing: boolean
}) {
return (
<tr className="hover:bg-slate-50">
{renderCells(kind, row)}
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={onEdit}
title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={onRemove}
title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
disabled={removing}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
)
}
function renderCells(kind: Kind, row: ConfigRow) {
const active = row.isActive as boolean | undefined
const statusBadge = active ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)
const checkBadge = (v: boolean | undefined) => v
? <span className="text-[11px] text-emerald-700"></span>
: <span className="text-[11px] text-slate-400"></span>
if (kind === 'leave-types') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{Number(row.daysPerYear ?? 0)}</td>
<td className="px-3 py-2">{checkBadge(row.isPaid as boolean)}</td>
<td className="px-3 py-2">{checkBadge(row.requiresAttachment as boolean)}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'holidays') {
const dateStr = row.date as string | null
const formatted = dateStr ? formatDateVi(dateStr) : '—'
return (
<>
<td className="px-3 py-2 text-xs">{row.year as number}</td>
<td className="px-3 py-2 font-mono text-xs">{formatted}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2">{checkBadge(row.isRecurring as boolean)}</td>
<td className="px-3 py-2">{checkBadge(row.isPaid as boolean)}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'shifts') {
const start = (row.startTime as string ?? '').slice(0, 5)
const end = (row.endTime as string ?? '').slice(0, 5)
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{start} {end}</td>
<td className="px-3 py-2 text-xs text-slate-600">{row.breakMinutes as number ?? 0}'</td>
<td className="px-3 py-2 text-xs text-slate-600">{formatWorkDays(row.workDays as string ?? '')}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
// ot-policies
const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0)
const mh = Number(row.multiplierHoliday ?? 0)
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">x{mw} / x{me} / x{mh}</td>
<td className="px-3 py-2 text-xs text-slate-600">{Number(row.maxHoursPerYear ?? 0)}h</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
function renderField(field: FieldDef, value: unknown, onChange: (v: unknown) => void) {
switch (field.type) {
case 'text':
return (
<Input
type="text"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
placeholder={field.placeholder}
/>
)
case 'date':
return (
<Input
type="date"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
/>
)
case 'time':
return (
<Input
type="time"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
/>
)
case 'number':
return (
<Input
type="number"
step={field.step ?? 1}
value={value === '' || value === undefined || value === null ? '' : String(value)}
onChange={e => onChange(e.target.value === '' ? '' : Number(e.target.value))}
required={field.required}
placeholder={field.placeholder}
/>
)
case 'textarea':
return (
<Textarea
rows={3}
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
placeholder={field.placeholder}
/>
)
case 'checkbox':
return (
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">{field.label.replace(/ \*$/, '')}</span>
</div>
)
case 'multiselect-weekday': {
const selected = ((value as string) ?? '').split(',').map(s => s.trim()).filter(Boolean)
return (
<div className="flex flex-wrap gap-2 pt-1">
{WEEKDAY_OPTIONS.map(o => {
const checked = selected.includes(o.value)
return (
<label
key={o.value}
className={cn(
'flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs cursor-pointer transition',
checked
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={e => {
const next = e.target.checked
? [...selected, o.value]
: selected.filter(x => x !== o.value)
// Preserve canonical order (Mon→Sun) to keep DB string stable
const canonical = WEEKDAY_OPTIONS.map(opt => opt.value).filter(v => next.includes(v))
onChange(canonical.join(','))
}}
className="h-3.5 w-3.5 accent-brand-600"
/>
<span>{o.label}</span>
</label>
)
})}
</div>
)
}
}
}
function buildBody(kind: Kind, form: Record<string, unknown>, isEdit: boolean): Record<string, unknown> {
const fields = KIND_CONFIG[kind].fields.map(f => f.key)
const body: Record<string, unknown> = {}
for (const k of fields) {
const f = KIND_CONFIG[kind].fields.find(x => x.key === k)!
const v = form[k]
if (f.type === 'checkbox') {
body[k] = !!v
} else if (f.type === 'number') {
body[k] = v === '' || v === undefined || v === null ? 0 : Number(v)
} else if (f.type === 'textarea' || f.type === 'text') {
// Nullable text → null if empty (BE Description? expects null, not '')
body[k] = v === '' || v === undefined ? null : v
} else {
body[k] = v ?? null
}
}
if (isEdit) body.isActive = !!form.isActive
return body
}
function formatDateVi(iso: string): string {
// ISO 'YYYY-MM-DD' → 'DD/MM'
const [, m, d] = iso.split('-')
if (!m || !d) return iso
return `${d}/${m}`
}
const WEEKDAY_LABEL_MAP: Record<string, string> = Object.fromEntries(
WEEKDAY_OPTIONS.map(o => [o.value, o.label]),
)
function formatWorkDays(csv: string): string {
return csv.split(',').map(s => s.trim()).filter(Boolean).map(v => WEEKDAY_LABEL_MAP[v] ?? v).join(', ')
}

View File

@ -0,0 +1,104 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities.
// Phase 10.2 G-H2 (Mig 35 — S34 schema + S35 BE CRUD wire + FE this file).
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies'
// ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
export type LeaveTypeDto = {
id: string
code: string
name: string
daysPerYear: number
isPaid: boolean
requiresAttachment: boolean
isActive: boolean
description: string | null
}
export type HolidayDto = {
id: string
year: number
date: string // ISO date 'YYYY-MM-DD' (DateOnly serialized)
name: string
isRecurring: boolean
isPaid: boolean
isActive: boolean
description: string | null
}
export type ShiftPatternDto = {
id: string
code: string
name: string
startTime: string // 'HH:mm[:ss]' (TimeOnly serialized)
endTime: string
breakMinutes: number
workDays: string // comma-separated 'Mon,Tue,Wed,Thu,Fri'
isActive: boolean
description: string | null
}
export type OtPolicyDto = {
id: string
code: string
name: string
multiplierWeekday: number
multiplierWeekend: number
multiplierHoliday: number
maxHoursPerDay: number
maxHoursPerMonth: number
maxHoursPerYear: number
isActive: boolean
description: string | null
}
// ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = {
code: string
name: string
daysPerYear: number
isPaid: boolean
requiresAttachment: boolean
description: string | null
}
export type UpdateLeaveTypeInput = CreateLeaveTypeInput & { id: string; isActive: boolean }
export type CreateHolidayInput = {
year: number
date: string
name: string
isRecurring: boolean
isPaid: boolean
description: string | null
}
export type UpdateHolidayInput = CreateHolidayInput & { id: string; isActive: boolean }
export type CreateShiftInput = {
code: string
name: string
startTime: string
endTime: string
breakMinutes: number
workDays: string
description: string | null
}
export type UpdateShiftInput = CreateShiftInput & { id: string; isActive: boolean }
export type CreateOtPolicyInput = {
code: string
name: string
multiplierWeekday: number
multiplierWeekend: number
multiplierHoliday: number
maxHoursPerDay: number
maxHoursPerMonth: number
maxHoursPerYear: number
description: string | null
}
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean }

View File

@ -21,6 +21,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage' import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage' import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage' import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
function App() { function App() {
@ -57,6 +58,9 @@ function App() {
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} /> <Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} /> <Route path="/employees/new" element={<EmployeeCreatePage />} />
{/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */}
<Route path="/hrm/configs" element={<Navigate to="/hrm/configs/leave-types" replace />} />
<Route path="/hrm/configs/:kind" element={<HrmConfigsPage />} />
{/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */}
<Route path="/directory" element={<InternalDirectoryPage />} /> <Route path="/directory" element={<InternalDirectoryPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -77,6 +77,12 @@ function resolvePath(key: string): string | null {
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới // 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. // — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees', Hrm_HoSo: '/employees',
// [Phase 10.2 G-H2 S35 2026-05-28] Cấu hình HRM 4 catalog leaf (Mig 35).
// Pattern 16-bis 4-place mirror (staticMap = 4th place, dễ miss nhất).
Hrm_Config_LeaveTypes: '/hrm/configs/leave-types',
Hrm_Config_Holidays: '/hrm/configs/holidays',
Hrm_Config_Shifts: '/hrm/configs/shifts',
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies',
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ. // [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
Off_DanhBa: '/directory', Off_DanhBa: '/directory',

View File

@ -0,0 +1,580 @@
// HRM Configs CRUD — 1 page handle 4 kind: leave-types / holidays / shifts / ot-policies.
// URL `/hrm/configs/:kind` driven, mỗi kind có fields config riêng + columns table riêng.
// Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh.
// Pattern 12-bis × 16-bis cookie-cutter mirror Master/Catalogs/CatalogsPage.tsx
// (9th cumulative S35 — declarative KIND_CONFIG Record + URL `:kind` param).
import { useState, type ComponentType, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { Settings2, Calendar, Clock, Pencil, Plus, Plane, Repeat, Trash2, Search } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { HrmConfigKind } from '@/types/hrm-config'
type Kind = HrmConfigKind
type FieldType = 'text' | 'textarea' | 'checkbox' | 'number' | 'date' | 'time' | 'multiselect-weekday'
type FieldDef = {
key: string
label: string
type: FieldType
required?: boolean
placeholder?: string
step?: number // for number type
}
const WEEKDAY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'Mon', label: 'T2' },
{ value: 'Tue', label: 'T3' },
{ value: 'Wed', label: 'T4' },
{ value: 'Thu', label: 'T5' },
{ value: 'Fri', label: 'T6' },
{ value: 'Sat', label: 'T7' },
{ value: 'Sun', label: 'CN' },
]
const KIND_CONFIG: Record<Kind, {
label: string
description: string
icon: ComponentType<{ className?: string }>
fields: FieldDef[]
columns: string[]
}> = {
'leave-types': {
label: 'Loại phép',
description: 'Danh mục loại phép (phép năm, ốm, không lương...) — dùng khi nhân viên đăng ký nghỉ.',
icon: Plane,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'ANNUAL, SICK, UNPAID...' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Phép năm, Phép ốm...' },
{ key: 'daysPerYear', label: 'Số ngày/năm', type: 'number', step: 0.5, placeholder: '12, 30...' },
{ key: 'isPaid', label: 'Có lương', type: 'checkbox' },
{ key: 'requiresAttachment', label: 'Yêu cầu giấy tờ đính kèm', type: 'checkbox' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Ngày/năm', 'Có lương', 'Đính kèm', 'Trạng thái'],
},
'holidays': {
label: 'Ngày lễ',
description: 'Lịch nghỉ lễ theo năm — Tết Dương lịch, Tết Âm, 30/4, 1/5, 2/9...',
icon: Calendar,
fields: [
{ key: 'year', label: 'Năm *', type: 'number', required: true, placeholder: '2026' },
{ key: 'date', label: 'Ngày *', type: 'date', required: true },
{ key: 'name', label: 'Tên ngày lễ *', type: 'text', required: true, placeholder: 'Tết Dương lịch, 30/4...' },
{ key: 'isRecurring', label: 'Lặp lại hàng năm (vd 1/1)', type: 'checkbox' },
{ key: 'isPaid', label: 'Có lương', type: 'checkbox' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Năm', 'Ngày', 'Tên', 'Lặp lại', 'Có lương', 'Trạng thái'],
},
'shifts': {
label: 'Ca làm việc',
description: 'Cấu hình ca làm việc (hành chính, ca sáng, ca chiều...) — dùng cho timesheet + chấm công.',
icon: Clock,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'HC, CA1, CA2...' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Hành chính, Ca sáng...' },
{ key: 'startTime', label: 'Giờ bắt đầu *', type: 'time', required: true },
{ key: 'endTime', label: 'Giờ kết thúc *', type: 'time', required: true },
{ key: 'breakMinutes', label: 'Nghỉ giữa ca (phút)', type: 'number', placeholder: '60' },
{ key: 'workDays', label: 'Ngày làm việc trong tuần *', type: 'multiselect-weekday', required: true },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Thời gian', 'Nghỉ', 'Ngày làm', 'Trạng thái'],
},
'ot-policies': {
label: 'Chính sách OT',
description: 'Cấu hình hệ số OT + giới hạn giờ/ngày, giờ/tháng, giờ/năm.',
icon: Repeat,
fields: [
{ key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'STANDARD' },
{ key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Chính sách OT chuẩn' },
{ key: 'multiplierWeekday', label: 'Hệ số ngày thường *', type: 'number', required: true, step: 0.1, placeholder: '1.5' },
{ key: 'multiplierWeekend', label: 'Hệ số cuối tuần *', type: 'number', required: true, step: 0.1, placeholder: '2.0' },
{ key: 'multiplierHoliday', label: 'Hệ số ngày lễ *', type: 'number', required: true, step: 0.1, placeholder: '3.0' },
{ key: 'maxHoursPerDay', label: 'Max giờ/ngày *', type: 'number', required: true, placeholder: '4' },
{ key: 'maxHoursPerMonth', label: 'Max giờ/tháng *', type: 'number', required: true, placeholder: '40' },
{ key: 'maxHoursPerYear', label: 'Max giờ/năm *', type: 'number', required: true, placeholder: '200' },
{ key: 'description', label: 'Mô tả', type: 'textarea' },
],
columns: ['Mã', 'Tên', 'Hệ số WD/WE/HL', 'Max h/year', 'Trạng thái'],
},
}
const KINDS: Kind[] = ['leave-types', 'holidays', 'shifts', 'ot-policies']
type ConfigRow = Record<string, unknown> & { id: string; isActive?: boolean }
export function HrmConfigsPage() {
const navigate = useNavigate()
const params = useParams<{ kind?: string }>()
const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'leave-types') as Kind
const config = KIND_CONFIG[kind]
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Record<string, unknown>>({})
const isEdit = !!form.id
const list = useQuery({
queryKey: ['hrm-configs', kind, search],
queryFn: async () => (await api.get<ConfigRow[]>(`/hrm-configs/${kind}`, { params: { q: search || undefined } })).data,
})
const save = useMutation({
mutationFn: async () => {
const body = buildBody(kind, form, isEdit)
if (isEdit) await api.put(`/hrm-configs/${kind}/${form.id}`, { id: form.id, ...body })
else await api.post(`/hrm-configs/${kind}`, body)
},
onSuccess: () => {
toast.success(isEdit ? 'Đã lưu' : 'Đã thêm')
qc.invalidateQueries({ queryKey: ['hrm-configs', kind] })
setOpen(false)
setForm({})
},
onError: err => toast.error(getErrorMessage(err)),
})
const remove = useMutation({
mutationFn: async (id: string) => { await api.delete(`/hrm-configs/${kind}/${id}`) },
onSuccess: () => {
toast.success('Đã xóa')
qc.invalidateQueries({ queryKey: ['hrm-configs', kind] })
},
onError: err => toast.error(getErrorMessage(err)),
})
function openCreate() {
const init: Record<string, unknown> = {}
config.fields.forEach(f => {
if (f.type === 'checkbox') init[f.key] = false
else if (f.type === 'number') init[f.key] = ''
else init[f.key] = ''
})
// Per-kind smart defaults
if (kind === 'leave-types') init.isPaid = true
if (kind === 'holidays') {
init.year = new Date().getFullYear()
init.isPaid = true
}
if (kind === 'shifts') {
init.startTime = '08:00'
init.endTime = '17:00'
init.breakMinutes = 60
init.workDays = 'Mon,Tue,Wed,Thu,Fri'
}
if (kind === 'ot-policies') {
init.multiplierWeekday = 1.5
init.multiplierWeekend = 2.0
init.multiplierHoliday = 3.0
init.maxHoursPerDay = 4
init.maxHoursPerMonth = 40
init.maxHoursPerYear = 200
}
setForm(init)
setOpen(true)
}
function openEdit(row: ConfigRow) {
const init: Record<string, unknown> = { id: row.id, isActive: row.isActive ?? true }
config.fields.forEach(f => {
const raw = row[f.key]
if (f.type === 'checkbox') init[f.key] = !!raw
else if (f.type === 'time' && typeof raw === 'string') init[f.key] = raw.slice(0, 5) // 'HH:mm:ss' → 'HH:mm'
else init[f.key] = raw ?? (f.type === 'number' ? '' : '')
})
setForm(init)
setOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
Cấu hình HRM
</span>
}
description="Catalog cấu hình dùng chung cho module Nhân sự — Loại phép / Ngày lễ / Ca làm việc / Chính sách OT."
/>
{/* Sub-tabs cho 4 kind */}
<div className="mb-4 flex flex-wrap gap-1 border-b border-slate-200">
{KINDS.map(k => {
const KIcon = KIND_CONFIG[k].icon
const active = k === kind
return (
<button
key={k}
onClick={() => navigate(`/hrm/configs/${k}`)}
className={cn(
'-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition',
active
? 'border-brand-600 font-semibold text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
<KIcon className="h-4 w-4" />
{KIND_CONFIG[k].label}
</button>
)
})}
</div>
<p className="mb-3 text-xs text-slate-500">{config.description}</p>
<div className="mb-3 flex gap-2">
<div className="relative max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={kind === 'holidays' ? 'Tìm theo tên ngày lễ…' : 'Tìm theo mã / tên…'}
className="pl-8"
/>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Thêm {config.label.toLowerCase()}
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
{config.columns.map(c => (
<th key={c} className="px-3 py-2 text-left">{c}</th>
))}
<th className="w-20 px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.isLoading && (
<tr><td colSpan={config.columns.length + 1} className="p-6 text-center text-slate-400">Đang tải</td></tr>
)}
{!list.isLoading && (list.data?.length ?? 0) === 0 && (
<tr><td colSpan={config.columns.length + 1} className="p-6 text-center text-slate-400">Chưa dữ liệu bấm Thêm đ tạo mới.</td></tr>
)}
{list.data?.map(row => (
<RowRenderer
key={row.id}
kind={kind}
row={row}
onEdit={() => openEdit(row)}
onRemove={() => {
const label = (row.name as string) ?? (row.code as string) ?? row.id
if (confirm(`Xóa "${label}"?`)) remove.mutate(row.id)
}}
removing={remove.isPending}
/>
))}
</tbody>
</table>
</div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>Hủy</Button>
<Button
onClick={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
disabled={save.isPending}
>
{save.isPending ? 'Đang lưu…' : (isEdit ? 'Lưu' : 'Thêm')}
</Button>
</>
}
>
<form
onSubmit={(e: FormEvent) => { e.preventDefault(); save.mutate() }}
className="grid grid-cols-2 gap-3"
>
{config.fields.map(f => (
<div
key={f.key}
className={cn(
'space-y-1',
(f.type === 'textarea' || f.type === 'multiselect-weekday') && 'col-span-2',
)}
>
<Label>{f.label}</Label>
{renderField(f, form[f.key], v => setForm(s => ({ ...s, [f.key]: v })))}
</div>
))}
{isEdit && (
<div className="col-span-2 space-y-1">
<Label>Trạng thái</Label>
<div className="flex items-center gap-2 pt-1">
<input
type="checkbox"
checked={!!form.isActive}
onChange={e => setForm(s => ({ ...s, isActive: e.target.checked }))}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">Đang dùng</span>
</div>
</div>
)}
</form>
</Dialog>
</div>
)
}
// ========== Helper components / functions ==========
function RowRenderer({
kind, row, onEdit, onRemove, removing,
}: {
kind: Kind
row: ConfigRow
onEdit: () => void
onRemove: () => void
removing: boolean
}) {
return (
<tr className="hover:bg-slate-50">
{renderCells(kind, row)}
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={onEdit}
title="Sửa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={onRemove}
title="Xóa"
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
disabled={removing}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
)
}
function renderCells(kind: Kind, row: ConfigRow) {
const active = row.isActive as boolean | undefined
const statusBadge = active ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Tạm tắt</span>
)
const checkBadge = (v: boolean | undefined) => v
? <span className="text-[11px] text-emerald-700"></span>
: <span className="text-[11px] text-slate-400"></span>
if (kind === 'leave-types') {
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 text-xs text-slate-600">{Number(row.daysPerYear ?? 0)}</td>
<td className="px-3 py-2">{checkBadge(row.isPaid as boolean)}</td>
<td className="px-3 py-2">{checkBadge(row.requiresAttachment as boolean)}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'holidays') {
const dateStr = row.date as string | null
const formatted = dateStr ? formatDateVi(dateStr) : '—'
return (
<>
<td className="px-3 py-2 text-xs">{row.year as number}</td>
<td className="px-3 py-2 font-mono text-xs">{formatted}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2">{checkBadge(row.isRecurring as boolean)}</td>
<td className="px-3 py-2">{checkBadge(row.isPaid as boolean)}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
if (kind === 'shifts') {
const start = (row.startTime as string ?? '').slice(0, 5)
const end = (row.endTime as string ?? '').slice(0, 5)
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">{start} {end}</td>
<td className="px-3 py-2 text-xs text-slate-600">{row.breakMinutes as number ?? 0}'</td>
<td className="px-3 py-2 text-xs text-slate-600">{formatWorkDays(row.workDays as string ?? '')}</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
// ot-policies
const mw = Number(row.multiplierWeekday ?? 0)
const me = Number(row.multiplierWeekend ?? 0)
const mh = Number(row.multiplierHoliday ?? 0)
return (
<>
<td className="px-3 py-2 font-mono text-xs">{row.code as string}</td>
<td className="px-3 py-2">{row.name as string}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-600">x{mw} / x{me} / x{mh}</td>
<td className="px-3 py-2 text-xs text-slate-600">{Number(row.maxHoursPerYear ?? 0)}h</td>
<td className="px-3 py-2">{statusBadge}</td>
</>
)
}
function renderField(field: FieldDef, value: unknown, onChange: (v: unknown) => void) {
switch (field.type) {
case 'text':
return (
<Input
type="text"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
placeholder={field.placeholder}
/>
)
case 'date':
return (
<Input
type="date"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
/>
)
case 'time':
return (
<Input
type="time"
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
required={field.required}
/>
)
case 'number':
return (
<Input
type="number"
step={field.step ?? 1}
value={value === '' || value === undefined || value === null ? '' : String(value)}
onChange={e => onChange(e.target.value === '' ? '' : Number(e.target.value))}
required={field.required}
placeholder={field.placeholder}
/>
)
case 'textarea':
return (
<Textarea
rows={3}
value={(value as string) ?? ''}
onChange={e => onChange(e.target.value)}
placeholder={field.placeholder}
/>
)
case 'checkbox':
return (
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<span className="text-sm text-slate-600">{field.label.replace(/ \*$/, '')}</span>
</div>
)
case 'multiselect-weekday': {
const selected = ((value as string) ?? '').split(',').map(s => s.trim()).filter(Boolean)
return (
<div className="flex flex-wrap gap-2 pt-1">
{WEEKDAY_OPTIONS.map(o => {
const checked = selected.includes(o.value)
return (
<label
key={o.value}
className={cn(
'flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs cursor-pointer transition',
checked
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={e => {
const next = e.target.checked
? [...selected, o.value]
: selected.filter(x => x !== o.value)
// Preserve canonical order (Mon→Sun) to keep DB string stable
const canonical = WEEKDAY_OPTIONS.map(opt => opt.value).filter(v => next.includes(v))
onChange(canonical.join(','))
}}
className="h-3.5 w-3.5 accent-brand-600"
/>
<span>{o.label}</span>
</label>
)
})}
</div>
)
}
}
}
function buildBody(kind: Kind, form: Record<string, unknown>, isEdit: boolean): Record<string, unknown> {
const fields = KIND_CONFIG[kind].fields.map(f => f.key)
const body: Record<string, unknown> = {}
for (const k of fields) {
const f = KIND_CONFIG[kind].fields.find(x => x.key === k)!
const v = form[k]
if (f.type === 'checkbox') {
body[k] = !!v
} else if (f.type === 'number') {
body[k] = v === '' || v === undefined || v === null ? 0 : Number(v)
} else if (f.type === 'textarea' || f.type === 'text') {
// Nullable text → null if empty (BE Description? expects null, not '')
body[k] = v === '' || v === undefined ? null : v
} else {
body[k] = v ?? null
}
}
if (isEdit) body.isActive = !!form.isActive
return body
}
function formatDateVi(iso: string): string {
// ISO 'YYYY-MM-DD' → 'DD/MM'
const [, m, d] = iso.split('-')
if (!m || !d) return iso
return `${d}/${m}`
}
const WEEKDAY_LABEL_MAP: Record<string, string> = Object.fromEntries(
WEEKDAY_OPTIONS.map(o => [o.value, o.label]),
)
function formatWorkDays(csv: string): string {
return csv.split(',').map(s => s.trim()).filter(Boolean).map(v => WEEKDAY_LABEL_MAP[v] ?? v).join(', ')
}

View File

@ -0,0 +1,104 @@
// Types cho HRM Config module — mirror BE Domain.Hrm.* entities.
// Phase 10.2 G-H2 (Mig 35 — S34 schema + S35 BE CRUD wire + FE this file).
export type HrmConfigKind = 'leave-types' | 'holidays' | 'shifts' | 'ot-policies'
// ========== DTOs (mirror BE DTOs HrmConfigFeatures.cs records) ==========
export type LeaveTypeDto = {
id: string
code: string
name: string
daysPerYear: number
isPaid: boolean
requiresAttachment: boolean
isActive: boolean
description: string | null
}
export type HolidayDto = {
id: string
year: number
date: string // ISO date 'YYYY-MM-DD' (DateOnly serialized)
name: string
isRecurring: boolean
isPaid: boolean
isActive: boolean
description: string | null
}
export type ShiftPatternDto = {
id: string
code: string
name: string
startTime: string // 'HH:mm[:ss]' (TimeOnly serialized)
endTime: string
breakMinutes: number
workDays: string // comma-separated 'Mon,Tue,Wed,Thu,Fri'
isActive: boolean
description: string | null
}
export type OtPolicyDto = {
id: string
code: string
name: string
multiplierWeekday: number
multiplierWeekend: number
multiplierHoliday: number
maxHoursPerDay: number
maxHoursPerMonth: number
maxHoursPerYear: number
isActive: boolean
description: string | null
}
// ========== Create/Update Input Commands (mirror BE record Command shape) ==========
export type CreateLeaveTypeInput = {
code: string
name: string
daysPerYear: number
isPaid: boolean
requiresAttachment: boolean
description: string | null
}
export type UpdateLeaveTypeInput = CreateLeaveTypeInput & { id: string; isActive: boolean }
export type CreateHolidayInput = {
year: number
date: string
name: string
isRecurring: boolean
isPaid: boolean
description: string | null
}
export type UpdateHolidayInput = CreateHolidayInput & { id: string; isActive: boolean }
export type CreateShiftInput = {
code: string
name: string
startTime: string
endTime: string
breakMinutes: number
workDays: string
description: string | null
}
export type UpdateShiftInput = CreateShiftInput & { id: string; isActive: boolean }
export type CreateOtPolicyInput = {
code: string
name: string
multiplierWeekday: number
multiplierWeekend: number
multiplierHoliday: number
maxHoursPerDay: number
maxHoursPerMonth: number
maxHoursPerYear: number
description: string | null
}
export type UpdateOtPolicyInput = CreateOtPolicyInput & { id: string; isActive: boolean }