[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:
@ -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 />} />
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
580
fe-admin/src/pages/hrm/HrmConfigsPage.tsx
Normal file
580
fe-admin/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-admin/src/types/hrm-config.ts
Normal file
104
fe-admin/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 }
|
||||||
@ -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 />} />
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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