All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s
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>
225 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|