From 06a441cf4e75cbaa081653038ae8135ece6e7131 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 22 May 2026 10:56:11 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User:=20Plan=20CA=20Chunk=20B=20?= =?UTF-8?q?=E2=80=94=20Move=204=20master=20pages=20t=E1=BB=AB=20fe-admin?= =?UTF-8?q?=20=E2=86=92=20fe-user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy SuppliersPage/ProjectsPage/DepartmentsPage/CatalogsPage (948 LOC mirror) - Extend menuKeys.ts với 5 key Catalogs* (CatalogUnits/Materials/Services/WorkItems) - Add 7 route App.tsx (/master/suppliers + /master/projects + /master/departments + 4 catalogs tab) - fe-user component parity verified (DataTable, PageHeader, PermissionGuard, 6 shadcn ui) Verify: - fe-user npm run build PASS 0 TS err (1916 modules, 14.14s) - 4 file SHA256 byte-identical mirror fe-admin (all 4 hash match) - 0 BE touch (Chunk A em main solo parallel) Pending Chunk C: sidebar filter 2 app (fe-admin HIDE 9 menu, fe-user SHOW) Pending Chunk D: smoke verify + role demo user Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-user/src/App.tsx | 9 + fe-user/src/lib/menuKeys.ts | 5 + fe-user/src/pages/master/CatalogsPage.tsx | 321 +++++++++++++++++++ fe-user/src/pages/master/DepartmentsPage.tsx | 160 +++++++++ fe-user/src/pages/master/ProjectsPage.tsx | 214 +++++++++++++ fe-user/src/pages/master/SuppliersPage.tsx | 253 +++++++++++++++ 6 files changed, 962 insertions(+) create mode 100644 fe-user/src/pages/master/CatalogsPage.tsx create mode 100644 fe-user/src/pages/master/DepartmentsPage.tsx create mode 100644 fe-user/src/pages/master/ProjectsPage.tsx create mode 100644 fe-user/src/pages/master/SuppliersPage.tsx diff --git a/fe-user/src/App.tsx b/fe-user/src/App.tsx index dc8c821..7c8cf01 100644 --- a/fe-user/src/App.tsx +++ b/fe-user/src/App.tsx @@ -6,6 +6,10 @@ import { Layout } from '@/components/Layout' import { LoginPage } from '@/pages/LoginPage' import { UserDashboardPage } from '@/pages/UserDashboardPage' import { InboxPage } from '@/pages/InboxPage' +import { SuppliersPage } from '@/pages/master/SuppliersPage' +import { ProjectsPage } from '@/pages/master/ProjectsPage' +import { DepartmentsPage } from '@/pages/master/DepartmentsPage' +import { CatalogsPage } from '@/pages/master/CatalogsPage' import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { MyContractsPage } from '@/pages/contracts/MyContractsPage' @@ -31,6 +35,11 @@ function App() { > } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/fe-user/src/lib/menuKeys.ts b/fe-user/src/lib/menuKeys.ts index cb6565c..efabf4b 100644 --- a/fe-user/src/lib/menuKeys.ts +++ b/fe-user/src/lib/menuKeys.ts @@ -5,6 +5,11 @@ export const MenuKeys = { Suppliers: 'Suppliers', Projects: 'Projects', Departments: 'Departments', + Catalogs: 'Catalogs', + CatalogUnits: 'CatalogUnits', + CatalogMaterials: 'CatalogMaterials', + CatalogServices: 'CatalogServices', + CatalogWorkItems: 'CatalogWorkItems', Contracts: 'Contracts', Forms: 'Forms', Reports: 'Reports', diff --git a/fe-user/src/pages/master/CatalogsPage.tsx b/fe-user/src/pages/master/CatalogsPage.tsx new file mode 100644 index 0000000..5bcdce2 --- /dev/null +++ b/fe-user/src/pages/master/CatalogsPage.tsx @@ -0,0 +1,321 @@ +// Generic master catalogs CRUD — 1 page handle 4 kind: units / materials / +// services / work-items. URL `/master/catalogs/:kind` driven, mỗi kind có +// fields config riêng. Sub-tabs hiển thị 4 kind trên cùng để chuyển nhanh. +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useParams, useNavigate } from 'react-router-dom' +import { Library, Pencil, Plus, Ruler, Package, Wrench, ListChecks, 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' + +type Kind = 'units' | 'materials' | 'services' | 'work-items' + +type FieldDef = { key: string; label: string; type: 'text' | 'textarea' | 'checkbox'; required?: boolean; placeholder?: string } + +const KIND_CONFIG: Record + fields: FieldDef[] + columns: string[] +}> = { + 'units': { + label: 'Đơn vị tính', + icon: Ruler, + fields: [ + { key: 'code', label: 'Mã *', type: 'text', required: true, placeholder: 'm2, kg, ngc...' }, + { key: 'name', label: 'Tên *', type: 'text', required: true, placeholder: 'Mét vuông, Kilogram...' }, + { key: 'description', label: 'Mô tả', type: 'textarea' }, + ], + columns: ['Mã', 'Tên', 'Mô tả'], + }, + 'materials': { + label: 'Vật tư / SP', + icon: Package, + fields: [ + { key: 'code', label: 'Mã SP *', type: 'text', required: true, placeholder: 'XM-PCB40' }, + { key: 'name', label: 'Tên SP *', type: 'text', required: true, placeholder: 'Xi măng PCB40' }, + { key: 'category', label: 'Nhóm', type: 'text', placeholder: 'Xi măng, Sắt thép...' }, + { key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'kg, m3...' }, + { key: 'specification', label: 'Thông số kỹ thuật', type: 'textarea' }, + { key: 'originCountry', label: 'Xuất xứ', type: 'text' }, + { key: 'isActive', label: 'Đang dùng', type: 'checkbox' }, + ], + columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'], + }, + 'services': { + label: 'Dịch vụ', + icon: Wrench, + fields: [ + { key: 'code', label: 'Mã DV *', type: 'text', required: true, placeholder: 'VC-OTO' }, + { key: 'name', label: 'Tên DV *', type: 'text', required: true, placeholder: 'Vận chuyển ô tô tải' }, + { key: 'category', label: 'Loại', type: 'text', placeholder: 'Vận chuyển, Bảo trì...' }, + { key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'ca, h, lan...' }, + { key: 'description', label: 'Mô tả', type: 'textarea' }, + { key: 'isActive', label: 'Đang dùng', type: 'checkbox' }, + ], + columns: ['Mã', 'Tên', 'Loại', 'ĐVT', 'Trạng thái'], + }, + 'work-items': { + label: 'Hạng mục công việc', + icon: ListChecks, + fields: [ + { key: 'code', label: 'Mã CV *', type: 'text', required: true, placeholder: 'DAO-MONG' }, + { key: 'name', label: 'Tên CV *', type: 'text', required: true, placeholder: 'Đào móng công trình' }, + { key: 'category', label: 'Nhóm', type: 'text', placeholder: 'Phần thô, Hoàn thiện...' }, + { key: 'defaultUnit', label: 'ĐVT mặc định', type: 'text', placeholder: 'm3, m2, kg...' }, + { key: 'description', label: 'Mô tả + spec yêu cầu', type: 'textarea' }, + { key: 'isActive', label: 'Đang dùng', type: 'checkbox' }, + ], + columns: ['Mã', 'Tên', 'Nhóm', 'ĐVT', 'Trạng thái'], + }, +} + +const KINDS: Kind[] = ['units', 'materials', 'services', 'work-items'] + +type CatalogRow = Record & { id: string; code: string; name: string } + +export function CatalogsPage() { + const navigate = useNavigate() + const params = useParams<{ kind?: string }>() + const kind = (KINDS.includes(params.kind as Kind) ? params.kind : 'units') 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: ['catalogs', kind, search], + queryFn: async () => (await api.get(`/catalogs/${kind}`, { params: { q: search || undefined } })).data, + }) + + const save = useMutation({ + mutationFn: async () => { + const body = buildBody(kind, form) + if (isEdit) await api.put(`/catalogs/${kind}/${form.id}`, { id: form.id, ...body }) + else await api.post(`/catalogs/${kind}`, body) + }, + onSuccess: () => { + toast.success(isEdit ? 'Đã lưu' : 'Đã thêm') + qc.invalidateQueries({ queryKey: ['catalogs', kind] }) + setOpen(false) + setForm({}) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + const remove = useMutation({ + mutationFn: async (id: string) => { await api.delete(`/catalogs/${kind}/${id}`) }, + onSuccess: () => { + toast.success('Đã xóa') + qc.invalidateQueries({ queryKey: ['catalogs', kind] }) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function openCreate() { + const init: Record = {} + config.fields.forEach(f => { init[f.key] = f.type === 'checkbox' ? true : '' }) + setForm(init) + setOpen(true) + } + function openEdit(row: CatalogRow) { + const init: Record = { id: row.id } + config.fields.forEach(f => { init[f.key] = row[f.key] ?? (f.type === 'checkbox' ? false : '') }) + setForm(init) + setOpen(true) + } + + return ( +
+ + + Danh mục chi tiết + + } + description="Master data dùng cho phần Chi tiết HĐ — autocomplete khi user nhập line items." + /> + + {/* Sub-tabs cho 4 kind */} +
+ {KINDS.map(k => { + const KIcon = KIND_CONFIG[k].icon + const active = k === kind + return ( + + ) + })} +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Tìm theo mã / tên…" + className="pl-8" + /> +
+ +
+ +
+ + + + + + {kind !== 'units' && } + {kind !== 'units' && } + {kind !== 'units' && } + + + + + {list.isLoading && ( + + )} + {!list.isLoading && (list.data?.length ?? 0) === 0 && ( + + )} + {list.data?.map(row => ( + + + + {kind !== 'units' && } + {kind !== 'units' && } + {kind !== 'units' && ( + + )} + + + ))} + +
TênNhóm/LoạiĐVTTrạng thái
Đang tải…
Chưa có dữ liệu — bấm Thêm để tạo mới.
{row.code}{row.name}{(row.category as string) ?? '—'}{(row.defaultUnit as string) ?? '—'} + {(row.isActive as boolean) ? ( + Đang dùng + ) : ( + Tạm tắt + )} + +
+ + +
+
+
+ + setOpen(false)} + title={`${isEdit ? 'Sửa' : 'Thêm'} ${config.label.toLowerCase()}`} + footer={ + <> + + + + } + > +
{ e.preventDefault(); save.mutate() }} + className="grid grid-cols-2 gap-3" + > + {config.fields.map(f => ( +
+ + {f.type === 'text' && ( + setForm(s => ({ ...s, [f.key]: e.target.value }))} + placeholder={f.placeholder} + required={f.required} + /> + )} + {f.type === 'textarea' && ( +