From 021674a66a56a226c56b0e159ebadff17d8477fc Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 28 May 2026 10:01:43 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin+FE-User:=20S35=20Plan=20G-H?= =?UTF-8?q?2=20Task=204=20=E2=80=94=20HrmConfigsPage=20declarative=204=20c?= =?UTF-8?q?atalog=20=C3=97=202=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fe-admin/src/App.tsx | 4 + fe-admin/src/components/Layout.tsx | 6 + fe-admin/src/pages/hrm/HrmConfigsPage.tsx | 580 ++++++++++++++++++++++ fe-admin/src/types/hrm-config.ts | 104 ++++ fe-user/src/App.tsx | 4 + fe-user/src/components/Layout.tsx | 6 + fe-user/src/pages/hrm/HrmConfigsPage.tsx | 580 ++++++++++++++++++++++ fe-user/src/types/hrm-config.ts | 104 ++++ 8 files changed, 1388 insertions(+) create mode 100644 fe-admin/src/pages/hrm/HrmConfigsPage.tsx create mode 100644 fe-admin/src/types/hrm-config.ts create mode 100644 fe-user/src/pages/hrm/HrmConfigsPage.tsx create mode 100644 fe-user/src/types/hrm-config.ts diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 455d47f..ad9035c 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -28,6 +28,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() { @@ -74,6 +75,9 @@ function App() { {/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */} } /> } /> + {/* Cấu hình HRM (Phase 10.2 G-H2 — Mig 35) */} + } /> + } /> {/* Văn phòng số — Danh bạ nội bộ (Phase 10.2 G-O1) */} } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index cbb9081..e6c7af9 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -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 // — nếu thiếu, MenuLeaf line ~198 `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', diff --git a/fe-admin/src/pages/hrm/HrmConfigsPage.tsx b/fe-admin/src/pages/hrm/HrmConfigsPage.tsx new file mode 100644 index 0000000..e600117 --- /dev/null +++ b/fe-admin/src/pages/hrm/HrmConfigsPage.tsx @@ -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 + 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 & { 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>({}) + const isEdit = !!form.id + + const list = useQuery({ + queryKey: ['hrm-configs', kind, search], + queryFn: async () => (await api.get(`/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 = {} + 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 = { 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 ( +
+ + + Cấu hình HRM + + } + 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 */} +
+ {KINDS.map(k => { + const KIcon = KIND_CONFIG[k].icon + const active = k === kind + return ( + + ) + })} +
+ +

{config.description}

+ +
+
+ + setSearch(e.target.value)} + placeholder={kind === 'holidays' ? 'Tìm theo tên ngày lễ…' : 'Tìm theo mã / tên…'} + className="pl-8" + /> +
+ +
+ +
+ + + + {config.columns.map(c => ( + + ))} + + + + + {list.isLoading && ( + + )} + {!list.isLoading && (list.data?.length ?? 0) === 0 && ( + + )} + {list.data?.map(row => ( + 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} + /> + ))} + +
{c}
Đang tải…
Chưa có dữ liệu — bấm Thêm để tạo mới.
+
+ + setOpen(false)} + title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`} + size="lg" + footer={ + <> + + + + } + > +
{ e.preventDefault(); save.mutate() }} + className="grid grid-cols-2 gap-3" + > + {config.fields.map(f => ( +
+ + {renderField(f, form[f.key], v => setForm(s => ({ ...s, [f.key]: v })))} +
+ ))} + {isEdit && ( +
+ +
+ setForm(s => ({ ...s, isActive: e.target.checked }))} + className="h-4 w-4 accent-brand-600" + /> + Đang dùng +
+
+ )} +
+
+
+ ) +} + +// ========== Helper components / functions ========== + +function RowRenderer({ + kind, row, onEdit, onRemove, removing, +}: { + kind: Kind + row: ConfigRow + onEdit: () => void + onRemove: () => void + removing: boolean +}) { + return ( + + {renderCells(kind, row)} + +
+ + +
+ + + ) +} + +function renderCells(kind: Kind, row: ConfigRow) { + const active = row.isActive as boolean | undefined + const statusBadge = active ? ( + Đang dùng + ) : ( + Tạm tắt + ) + const checkBadge = (v: boolean | undefined) => v + ? + : + + if (kind === 'leave-types') { + return ( + <> + {row.code as string} + {row.name as string} + {Number(row.daysPerYear ?? 0)} + {checkBadge(row.isPaid as boolean)} + {checkBadge(row.requiresAttachment as boolean)} + {statusBadge} + + ) + } + if (kind === 'holidays') { + const dateStr = row.date as string | null + const formatted = dateStr ? formatDateVi(dateStr) : '—' + return ( + <> + {row.year as number} + {formatted} + {row.name as string} + {checkBadge(row.isRecurring as boolean)} + {checkBadge(row.isPaid as boolean)} + {statusBadge} + + ) + } + if (kind === 'shifts') { + const start = (row.startTime as string ?? '').slice(0, 5) + const end = (row.endTime as string ?? '').slice(0, 5) + return ( + <> + {row.code as string} + {row.name as string} + {start} – {end} + {row.breakMinutes as number ?? 0}' + {formatWorkDays(row.workDays as string ?? '')} + {statusBadge} + + ) + } + // ot-policies + const mw = Number(row.multiplierWeekday ?? 0) + const me = Number(row.multiplierWeekend ?? 0) + const mh = Number(row.multiplierHoliday ?? 0) + return ( + <> + {row.code as string} + {row.name as string} + x{mw} / x{me} / x{mh} + {Number(row.maxHoursPerYear ?? 0)}h + {statusBadge} + + ) +} + +function renderField(field: FieldDef, value: unknown, onChange: (v: unknown) => void) { + switch (field.type) { + case 'text': + return ( + onChange(e.target.value)} + required={field.required} + placeholder={field.placeholder} + /> + ) + case 'date': + return ( + onChange(e.target.value)} + required={field.required} + /> + ) + case 'time': + return ( + onChange(e.target.value)} + required={field.required} + /> + ) + case 'number': + return ( + onChange(e.target.value === '' ? '' : Number(e.target.value))} + required={field.required} + placeholder={field.placeholder} + /> + ) + case 'textarea': + return ( +