[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
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:
@ -21,6 +21,7 @@ import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPa
|
||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
|
||||
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
|
||||
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
|
||||
import { InternalDirectoryPage } from '@/pages/office/InternalDirectoryPage'
|
||||
|
||||
function App() {
|
||||
@ -57,6 +58,9 @@ function App() {
|
||||
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
|
||||
<Route path="/employees" element={<EmployeesListPage />} />
|
||||
<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) */}
|
||||
<Route path="/directory" element={<InternalDirectoryPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@ -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
|
||||
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
|
||||
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ộ.
|
||||
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
|
||||
Off_DanhBa: '/directory',
|
||||
|
||||
580
fe-user/src/pages/hrm/HrmConfigsPage.tsx
Normal file
580
fe-user/src/pages/hrm/HrmConfigsPage.tsx
Normal 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 có 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(', ')
|
||||
}
|
||||
104
fe-user/src/types/hrm-config.ts
Normal file
104
fe-user/src/types/hrm-config.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user