Files
solution-erp/fe-admin/src/pages/office/WorkflowAppsListPage.tsx
pqhuy1987 c556f6cfa2
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s
[CLAUDE] FE: Văn phòng số re-skin toàn module — 10 page PURO layout + CSS Hồ sơ NS (PageHeader/KpiCard/WidgetCard), phẫu thuật giữ nguyên logic
Re-skin TRỌN module Office sang bố cục PURO (NamGroup) + ngôn ngữ thị giác Hồ sơ Nhân sự, tái dùng 3 shared component foundation. Phẫu thuật trình bày — logic byte-identical (reviewer verify mọi api.get/post/put/delete + queryKey zero-delta HEAD vs working tree, cả 2 app build PASS).

10 page (9 fe-user → mirror fe-admin SHA256-identical + AttendanceReport fe-admin-only):
- Danh bạ nội bộ — PageHeader + KpiCard tổng hợp (NV/phòng ban) + card icon-chip.
- Phòng họp (lịch + quản lý phòng) — PageHeader amberx + calendar/table trong card-accent.
- Đề xuất (List/Create/Detail) — List: status filter → KpiCard row (6 trạng thái + inbox "Cần tôi duyệt"); Create/Detail: card-accent section + Field idiom.
- Đơn từ/Đặt xe (List/Detail, :kind leave/ot/travel/vehicle) — PageHeader teal + KpiCard status filter (client-side view over fetched) + card-accent detail.
- Ticket CNTT — PageHeader violet + KpiCard 5-status filter + Quá hạn SLA + kanban card-accent.
- Báo cáo chấm công (fe-admin only) — PageHeader + KpiCard tổng hợp + bảng card-accent + Excel-export giữ nguyên.

- Accent chỉ dùng stop hợp lệ (teal/violet/amberx/greenx 50/100/500/600/700; brand 50-900); gotcha #66 clean. a11y giữ/nâng (focus-visible, KpiCard role/aria-pressed/keyboard).
- Build PASS x2 (fe-user index-C8-p69Kn / fe-admin index-yFhLO2Wp). reviewer PASS 0 blocker; 2 concern cosmetic (badge dup ProposalDetail header+row; KpiCard filter lọc trên trang đầu đã fetch — giới hạn pagination có sẵn).
- Office VẪN ẨN non-Admin (chưa golive). 9 page fe-user↔fe-admin SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:57:46 +07:00

225 lines
9.7 KiB
TypeScript

// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
// Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline).
// Re-skin S69 (2026-06-17): PURO layout (ui/PageHeader teal + KpiCard status-filter row)
// + Hồ sơ Nhân sự visual language (accent rail card, slate table chrome). Status filter is
// a CLIENT-SIDE view over the already-fetched items (no query/endpoint/navigation change).
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
// File MIRROR SHA256 identical fe-user counterpart.
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { CalendarOff, Clock, Plane, Car, FileSignature, Layers, Send, RotateCcw, CheckCircle2 } from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WorkflowAppStatus,
type PagedResult,
} from '@/types/workflowApps'
type Kind = 'leave' | 'ot' | 'travel' | 'vehicle'
const KIND_CONFIG: Record<Kind, {
title: string
description: string
endpoint: string
columns: Array<{ key: string; label: string; render: (item: any) => React.ReactNode }>
}> = {
leave: {
title: 'Đơn xin nghỉ phép',
description: 'Danh sách đơn nghỉ phép — Workflow V2 ApplicableType=5',
endpoint: '/leave-requests',
columns: [
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
{ key: 'startDate', label: 'Từ', render: (x) => new Date(x.startDate).toLocaleDateString('vi-VN') },
{ key: 'endDate', label: 'Đến', render: (x) => new Date(x.endDate).toLocaleDateString('vi-VN') },
{ key: 'numDays', label: 'Ngày', render: (x) => x.numDays },
{ key: 'reason', label: 'Lý do', render: (x) => <span className="truncate max-w-xs block">{x.reason}</span> },
],
},
ot: {
title: 'Đơn đăng ký OT',
description: 'Danh sách đơn OT — Workflow V2 ApplicableType=6',
endpoint: '/ot-requests',
columns: [
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
{ key: 'otDate', label: 'Ngày OT', render: (x) => new Date(x.otDate).toLocaleDateString('vi-VN') },
{ key: 'hours', label: 'Giờ', render: (x) => x.hours },
{ key: 'reason', label: 'Lý do', render: (x) => <span className="truncate max-w-xs block">{x.reason}</span> },
],
},
travel: {
title: 'Đơn đi công tác',
description: 'Danh sách đăng ký công tác',
endpoint: '/travel-requests',
columns: [
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
{ key: 'destination', label: 'Địa điểm', render: (x) => x.destination },
{ key: 'startDate', label: 'Từ', render: (x) => new Date(x.startDate).toLocaleDateString('vi-VN') },
{ key: 'endDate', label: 'Đến', render: (x) => new Date(x.endDate).toLocaleDateString('vi-VN') },
{ key: 'purpose', label: 'Mục đích', render: (x) => <span className="truncate max-w-xs block">{x.purpose}</span> },
],
},
vehicle: {
title: 'Đặt xe công',
description: 'Danh sách booking xe — Workflow V2 ApplicableType=7',
endpoint: '/vehicle-bookings',
columns: [
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
{ key: 'requesterFullName', label: 'Người đặt', render: (x) => x.requesterFullName },
{ key: 'vehicleLicense', label: 'Biển số', render: (x) => <span className="font-mono text-xs">{x.vehicleLicense}</span> },
{ key: 'destination', label: 'Đến', render: (x) => x.destination },
{ key: 'startAt', label: 'Bắt đầu', render: (x) => new Date(x.startAt).toLocaleString('vi-VN') },
{ key: 'driverName', label: 'Tài xế', render: (x) => x.driverName ?? '—' },
],
},
}
const ICON_MAP: Record<Kind, any> = {
leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car,
}
// Status filter chips (presentation): null = Tất cả. Each maps to a KpiCard accent.
type StatusFilter = number | null
export function WorkflowAppsListPage() {
const { kind = 'leave' } = useParams<{ kind: Kind }>()
const navigate = useNavigate()
const config = KIND_CONFIG[kind as Kind]
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
// Client-side status filter — a view over the fetched list (no extra query / endpoint).
const [statusFilter, setStatusFilter] = useState<StatusFilter>(null)
const list = useQuery({
queryKey: [config.endpoint, { page: 1 }],
queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data,
enabled: !!config,
})
const items = list.data?.items ?? []
// Counts per status + the visible (filtered) rows — derived presentation only.
const counts = useMemo(() => {
const c = { all: items.length, submitted: 0, returned: 0, approved: 0 }
for (const it of items) {
if (it.status === WorkflowAppStatus.DaGuiDuyet) c.submitted++
else if (it.status === WorkflowAppStatus.TraLai) c.returned++
else if (it.status === WorkflowAppStatus.DaDuyet) c.approved++
}
return c
}, [items])
const visibleItems = useMemo(
() => (statusFilter == null ? items : items.filter((it: any) => it.status === statusFilter)),
[items, statusFilter],
)
if (!config) {
return <div className="text-red-600">Module không tồn tại: {kind}</div>
}
return (
<div className="space-y-5">
<PageHeader
eyebrow="Văn phòng số · Đơn từ"
title={config.title}
subtitle={config.description}
icon={<Icon className="h-5 w-5" />}
accent="teal"
/>
{/* Status filter — row of KpiCards (PURO pattern, replaces tabs) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard
label="Tất cả"
value={counts.all}
icon={<Layers className="h-4 w-4" />}
accent="teal"
active={statusFilter == null}
onClick={() => setStatusFilter(null)}
/>
<KpiCard
label="Đã gửi duyệt"
value={counts.submitted}
icon={<Send className="h-4 w-4" />}
accent="amberx"
active={statusFilter === WorkflowAppStatus.DaGuiDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaGuiDuyet)}
/>
<KpiCard
label="Trả lại"
value={counts.returned}
icon={<RotateCcw className="h-4 w-4" />}
accent="violet"
active={statusFilter === WorkflowAppStatus.TraLai}
onClick={() => setStatusFilter(WorkflowAppStatus.TraLai)}
/>
<KpiCard
label="Đã duyệt"
value={counts.approved}
icon={<CheckCircle2 className="h-4 w-4" />}
accent="greenx"
active={statusFilter === WorkflowAppStatus.DaDuyet}
onClick={() => setStatusFilter(WorkflowAppStatus.DaDuyet)}
/>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50">
<tr>
{config.columns.map((c) => (
<th key={c.key} className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">{c.label}</th>
))}
<th className="px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500">Trạng thái</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={config.columns.length + 1} className="px-4 py-10 text-center text-slate-400">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && visibleItems.length === 0 && (
<tr>
<td colSpan={config.columns.length + 1} className="px-4 py-12 text-center text-slate-400">
<Icon className="mx-auto mb-2 h-8 w-8 opacity-40" />
{items.length === 0 ? 'Chưa có dữ liệu.' : 'Không có đơn nào ở trạng thái này.'}
</td>
</tr>
)}
{visibleItems.map((item: any) => (
<tr
key={item.id}
className="cursor-pointer border-b border-slate-100 transition hover:bg-teal-50/50"
onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)}
>
{config.columns.map((c) => (
<td key={c.key} className="px-4 py-2.5 text-slate-700">{c.render(item)}</td>
))}
<td className="px-4 py-2.5">
<span
className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
WORKFLOW_APP_STATUS_BADGE[item.status],
)}
>
{WORKFLOW_APP_STATUS_LABELS[item.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}