[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
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m42s
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>
This commit is contained in:
@ -2,11 +2,18 @@
|
||||
// fe-admin ONLY: endpoint /attendances/report* là [Authorize(Roles=Admin)] → fe-user KHÔNG có page này.
|
||||
// Filter Năm/Tháng/Phòng ban → TanStack Query → Table + footer Tổng. Xuất Excel qua api.get responseType:'blob'
|
||||
// (api instance đã inject JWT qua interceptor + hỗ trợ refresh-token retry — chuẩn hơn raw fetch).
|
||||
//
|
||||
// Re-skin S69 (2026-06-17): áp PURO layout + visual language Hồ sơ Nhân sự — CHỈ trình bày,
|
||||
// KHÔNG đổi logic. ui/PageHeader (icon-chip + actions) · KpiCard hàng tổng hợp · .card-accent
|
||||
// cho bộ lọc + bảng · .label-eyebrow nhãn lọc · text-brand-800 cho số quan trọng. Mọi query /
|
||||
// mutation / queryKey / endpoint / handler giữ NGUYÊN (attendance-report, /attendances/report,
|
||||
// /attendances/report/excel, departments-all-attendance-report).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Download, ClipboardList } from 'lucide-react'
|
||||
import { Download, ClipboardList, CalendarCheck, Users, Clock, Hourglass } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@ -68,12 +75,17 @@ export function AttendanceReportPage() {
|
||||
})
|
||||
|
||||
const rows = report.data?.rows ?? []
|
||||
// Tổng hợp tô màu KpiCard — thuần dẫn xuất từ dữ liệu đã fetch (KHÔNG gọi API mới).
|
||||
const totalDaysPresent = rows.reduce((s, r) => s + r.daysPresent, 0)
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Báo cáo chấm công"
|
||||
description="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban."
|
||||
subtitle="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban."
|
||||
icon={<CalendarCheck className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
<Button onClick={() => exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}>
|
||||
<Download className="h-4 w-4" />
|
||||
@ -82,10 +94,40 @@ export function AttendanceReportPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ===== Tổng hợp (KpiCard) — chỉ hiện khi có dữ liệu ===== */}
|
||||
{!report.isLoading && rows.length > 0 && report.data && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Nhân sự"
|
||||
value={rows.length}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tổng ngày công"
|
||||
value={fmtNum(totalDaysPresent)}
|
||||
icon={<CalendarCheck className="h-4 w-4" />}
|
||||
accent="teal"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tổng giờ làm"
|
||||
value={fmtNum(report.data.grandTotalWorkHours)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
accent="violet"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tổng OT quy đổi"
|
||||
value={fmtNum(report.data.grandTotalOtWeighted)}
|
||||
icon={<Hourglass className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Bộ lọc ===== */}
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="card-accent mb-4 flex flex-wrap items-end gap-3 p-4">
|
||||
<div className="w-28 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Năm</label>
|
||||
<label className="label-eyebrow block">Năm</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2000}
|
||||
@ -95,7 +137,7 @@ export function AttendanceReportPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Tháng</label>
|
||||
<label className="label-eyebrow block">Tháng</label>
|
||||
<Select value={month} onChange={e => setMonth(Number(e.target.value))}>
|
||||
{MONTHS.map(m => (
|
||||
<option key={m} value={m}>Tháng {m}</option>
|
||||
@ -103,7 +145,7 @@ export function AttendanceReportPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-56 flex-1 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Phòng ban</label>
|
||||
<label className="label-eyebrow block">Phòng ban</label>
|
||||
<Select value={deptId} onChange={e => setDeptId(e.target.value)}>
|
||||
<option value="">Tất cả phòng ban</option>
|
||||
{(departments.data ?? []).map(d => (
|
||||
@ -113,58 +155,75 @@ export function AttendanceReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Bảng kết quả ===== */}
|
||||
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">STT</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Ngày công</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Tổng giờ làm</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT thường</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT cuối tuần</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT lễ</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT quy đổi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.isLoading && (
|
||||
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải…</td></tr>
|
||||
)}
|
||||
{!report.isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
|
||||
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
Không có dữ liệu chấm công cho kỳ đã chọn.
|
||||
</td></tr>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.userId} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{r.fullName}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{r.daysPresent}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(r.totalWorkHours)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold tabular-nums text-slate-900">{fmtNum(r.otWeighted)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{!report.isLoading && rows.length > 0 && report.data && (
|
||||
<tfoot className="border-t-2 border-slate-300 bg-slate-50 font-semibold text-slate-800">
|
||||
<tr>
|
||||
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={3}></td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{/* ===== Bảng kết quả ===== card-accent + header bar brand-tint */}
|
||||
<section className="card-accent flex min-w-0 flex-col overflow-hidden">
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 bg-brand-50 px-4 py-2.5">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-100)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-brand-800">Bảng tổng hợp chấm công</h3>
|
||||
{rows.length > 0 && (
|
||||
<span className="rounded-full bg-white px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-brand-700">
|
||||
{rows.length}
|
||||
</span>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</header>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">STT</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Họ tên</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Phòng ban</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ngày công</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Tổng giờ làm</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">OT thường</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">OT cuối tuần</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">OT lễ</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">OT quy đổi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.isLoading && (
|
||||
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải…</td></tr>
|
||||
)}
|
||||
{!report.isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
|
||||
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
Không có dữ liệu chấm công cho kỳ đã chọn.
|
||||
</td></tr>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.userId} className="border-b border-slate-100 hover:bg-brand-50/40">
|
||||
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-medium text-brand-800">{r.fullName}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-700">{r.daysPresent}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-700">{fmtNum(r.totalWorkHours)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold tabular-nums text-brand-800">{fmtNum(r.otWeighted)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{!report.isLoading && rows.length > 0 && report.data && (
|
||||
<tfoot className="border-t-2 border-brand-200 bg-brand-50 font-semibold text-brand-800">
|
||||
<tr>
|
||||
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={3}></td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
// Danh bạ nội bộ (Internal Directory) — Phase 10.2 G-O1 (S34 2026-05-27).
|
||||
// Card grid responsive, filter search + department, avatar fallback gradient theo
|
||||
// userId hash stable. File này MIRROR SHA256 identical với fe-admin counterpart.
|
||||
// Reuse BE GET /api/directory readonly (DirectoryFeatures.cs).
|
||||
// Re-skin S69 (2026-06-17): PURO layout + ngôn ngữ thị giác Hồ sơ Nhân sự —
|
||||
// ui/PageHeader (eyebrow "Văn phòng số" + icon Contact + accent brand, search/
|
||||
// filter dồn vào actions slot) · KpiCard hàng tóm tắt (tổng người / số phòng ban,
|
||||
// inert vì không phải filter-status) · card người dùng .icon-chip cho avatar +
|
||||
// tên text-brand-800 + .label-eyebrow cho phòng ban + viền card sạch.
|
||||
// GIỮ brand #1F7DC1 + Be Vietnam Pro.
|
||||
// KHÔNG đổi logic — 100% chức năng giữ: 2 query (departments-all-directory /
|
||||
// directory) NGUYÊN, search box + Select phòng ban, URL params (q, deptId),
|
||||
// avatar gradient hash theo userId, mọi mailto/tel/handler giữ nguyên.
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Building2, Contact, Mail, Phone, Search, UserCircle2, Users } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@ -87,43 +94,64 @@ export function InternalDirectoryPage() {
|
||||
}
|
||||
|
||||
const total = list.data?.length ?? 0
|
||||
const deptCount = departments.data?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* PURO header: eyebrow + icon-chip brand + search/filter trong actions slot */}
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Danh bạ nội bộ"
|
||||
description={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
|
||||
subtitle={list.isLoading ? 'Đang tải...' : `${total} nhân viên`}
|
||||
icon={<Contact className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<form onSubmit={applySearch} className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={localSearch}
|
||||
onChange={e => setLocalSearch(e.target.value)}
|
||||
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||
className="pl-8 sm:w-72"
|
||||
/>
|
||||
</form>
|
||||
<Select
|
||||
value={departmentId}
|
||||
onChange={e => setParam('deptId', e.target.value || null)}
|
||||
className="sm:w-56"
|
||||
>
|
||||
<option value="">Tất cả phòng ban</option>
|
||||
{(departments.data ?? []).map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter bar sticky top */}
|
||||
<div className="sticky top-0 z-10 mb-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur sm:flex-row sm:items-center">
|
||||
<form onSubmit={applySearch} className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={localSearch}
|
||||
onChange={e => setLocalSearch(e.target.value)}
|
||||
onBlur={() => setParam('q', localSearch.trim() || null)}
|
||||
placeholder="Tìm tên / email / SĐT / mã NV..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</form>
|
||||
<Select
|
||||
value={departmentId}
|
||||
onChange={e => setParam('deptId', e.target.value || null)}
|
||||
className="sm:w-64"
|
||||
>
|
||||
<option value="">Tất cả phòng ban</option>
|
||||
{(departments.data ?? []).map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Hàng tóm tắt — counts có sẵn từ data (inert, KHÔNG phải filter trạng thái) */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-3 sm:max-w-md">
|
||||
<KpiCard
|
||||
label="Tổng nhân viên"
|
||||
value={total}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
accent="brand"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Số phòng ban"
|
||||
value={deptCount}
|
||||
icon={<Building2 className="h-4 w-4" />}
|
||||
accent="teal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card grid */}
|
||||
{list.isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-44 animate-pulse rounded-lg border border-slate-200 bg-slate-100" />
|
||||
<div key={i} className="h-44 animate-pulse rounded-xl border border-slate-200 bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
@ -145,7 +173,7 @@ export function InternalDirectoryPage() {
|
||||
|
||||
function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md motion-reduce:transform-none">
|
||||
{/* Top row: avatar + name + code */}
|
||||
<div className="flex items-start gap-3">
|
||||
{item.photoUrl ? (
|
||||
@ -166,7 +194,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-slate-900" title={item.fullName}>
|
||||
<h3 className="truncate text-sm font-semibold text-brand-800" title={item.fullName}>
|
||||
{item.fullName}
|
||||
</h3>
|
||||
{item.employeeCode && (
|
||||
@ -181,8 +209,9 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||
</p>
|
||||
)}
|
||||
{item.departmentName && (
|
||||
<span className="mt-1 inline-flex rounded bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-900">
|
||||
{item.departmentName}
|
||||
<span className="label-eyebrow mt-1.5 inline-flex max-w-full items-center gap-1 truncate">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{item.departmentName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -225,7 +254,7 @@ function DirectoryCard({ item }: { item: DirectoryItem }) {
|
||||
{item.internalPhone && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<UserCircle2 className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
<span className="inline-flex rounded bg-amber-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amber-900">
|
||||
<span className="inline-flex rounded bg-amberx-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-amberx-700">
|
||||
Ext: {item.internalPhone}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
|
||||
// Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
|
||||
// S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic.
|
||||
// S69 re-skin: PURO chrome + Hồ sơ NS visual language (PageHeader ui + KpiCard filter-row + card-accent).
|
||||
// KHÔNG đổi logic — mọi query/mutation/endpoint/handler/state giữ NGUYÊN:
|
||||
// list ['it-tickets'] · staffQ ['it-tickets','assignable-staff'] · reassign PUT /it-tickets/{id}/assign
|
||||
// · canReassign · staff · grouped · formatSlaDue · Dialog. statusKey/breached chỉ LỌC HIỂN THỊ
|
||||
// client-side (presentation), KHÔNG gọi API mới, KHÔNG sửa cách fetch.
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Ticket } from 'lucide-react'
|
||||
import {
|
||||
Pencil, Ticket, Inbox, Loader2, CheckCircle2, Archive, AlarmClockOff, User,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard, type Accent } from '@/components/ui/KpiCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@ -23,6 +31,36 @@ function formatSlaDue(iso: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Presentation-only filter chips (PURO KpiCard row) ─────────────────────────
|
||||
// 'all' = mặc định hiện mọi cột; statusKey = chỉ hiện 1 trạng thái; 'breached' =
|
||||
// chỉ hiện ticket quá hạn SLA. Đây là LỌC HIỂN THỊ client-side trên items đã fetch
|
||||
// — KHÔNG đổi query, KHÔNG gọi BE. Accent map mỗi trạng thái 1 tone Hồ sơ NS.
|
||||
type StatusChip = 1 | 2 | 3 | 4
|
||||
type FilterKey = 'all' | StatusChip | 'breached'
|
||||
|
||||
const STATUS_CHIPS: { key: StatusChip; icon: typeof Ticket; accent: Accent }[] = [
|
||||
{ key: 1, icon: Inbox, accent: 'violet' }, // Mới
|
||||
{ key: 2, icon: Loader2, accent: 'brand' }, // Đang xử lý
|
||||
{ key: 3, icon: CheckCircle2, accent: 'greenx' }, // Đã giải quyết
|
||||
{ key: 4, icon: Archive, accent: 'teal' }, // Đã đóng
|
||||
]
|
||||
|
||||
// Kanban column order (giữ nguyên thứ tự gốc 1,2,3,5,4) + tone cột để tô header.
|
||||
const COLUMN_ACCENT: Record<number, Accent> = {
|
||||
1: 'violet', 2: 'brand', 3: 'greenx', 5: 'amberx', 4: 'teal',
|
||||
}
|
||||
const COLUMN_RAIL: Record<Accent, string> = {
|
||||
brand: 'var(--color-brand-500)',
|
||||
teal: 'var(--color-teal-500)',
|
||||
violet: 'var(--color-violet-500)',
|
||||
amberx: 'var(--color-amberx-500)',
|
||||
greenx: 'var(--color-greenx-500)',
|
||||
}
|
||||
const COLUMN_HEAD: Record<Accent, string> = {
|
||||
brand: 'text-brand-700', teal: 'text-teal-700', violet: 'text-violet-700',
|
||||
amberx: 'text-amberx-700', greenx: 'text-greenx-700',
|
||||
}
|
||||
|
||||
export function ItTicketsPage() {
|
||||
const qc = useQueryClient()
|
||||
const list = useQuery({
|
||||
@ -34,6 +72,9 @@ export function ItTicketsPage() {
|
||||
const [target, setTarget] = useState<ItTicketDto | null>(null)
|
||||
const [pick, setPick] = useState('')
|
||||
|
||||
// Presentation-only: chip lọc hiển thị (KHÔNG ảnh hưởng fetch).
|
||||
const [filter, setFilter] = useState<FilterKey>('all')
|
||||
|
||||
// BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút
|
||||
// trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[].
|
||||
const staffQ = useQuery({
|
||||
@ -72,73 +113,136 @@ export function ItTicketsPage() {
|
||||
if (grouped[t.status]) grouped[t.status].push(t)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
|
||||
// Presentation-only derive: số ticket quá hạn SLA (cho KpiCard "Quá hạn SLA").
|
||||
const breachedCount = items.filter(t => t.slaBreached).length
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 5, 4].map((statusKey) => (
|
||||
<div key={statusKey} className="rounded-lg border bg-card p-3">
|
||||
<h3 className="font-medium text-sm mb-2">
|
||||
{IT_TICKET_STATUS_LABELS[statusKey]} <span className="text-xs text-muted-foreground">({grouped[statusKey].length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>}
|
||||
{!list.isLoading && grouped[statusKey].length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">Trống</div>
|
||||
)}
|
||||
{grouped[statusKey].map((t) => (
|
||||
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}>
|
||||
{IT_TICKET_PRIORITY_LABELS[t.priority]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium truncate">{t.title}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
|
||||
<span className="truncate" title={t.assignedToFullName ?? undefined}>
|
||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||
</span>
|
||||
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
|
||||
{canReassign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDialog(t)}
|
||||
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Đổi người xử lý"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{t.slaDueAt && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px] whitespace-nowrap',
|
||||
t.slaBreached ? 'bg-red-100 text-red-700 font-medium' : 'bg-slate-100 text-slate-600',
|
||||
)}
|
||||
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
|
||||
>
|
||||
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
// Cột nào hiển thị theo chip lọc (presentation). 'all' = mọi cột; statusKey = 1 cột;
|
||||
// 'breached' = mọi cột nhưng chỉ giữ ticket quá hạn (cardMatch lọc bên trong).
|
||||
const ORDER = [1, 2, 3, 5, 4]
|
||||
const visibleColumns =
|
||||
filter === 'all' || filter === 'breached'
|
||||
? ORDER
|
||||
: ORDER.filter(s => s === filter)
|
||||
const cardMatch = (t: ItTicketDto) => (filter === 'breached' ? t.slaBreached : true)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Ticket CNTT"
|
||||
subtitle="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật"
|
||||
icon={<Ticket className="h-5 w-5" />}
|
||||
accent="violet"
|
||||
/>
|
||||
|
||||
{/* ── KpiCard filter-row (PURO) — thay status tabs. value = count, onClick =
|
||||
set filter hiển thị. Bấm lại chip đang chọn → bỏ lọc (về 'all'). ── */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{STATUS_CHIPS.map(({ key, icon: Icon, accent }) => (
|
||||
<KpiCard
|
||||
key={key}
|
||||
label={IT_TICKET_STATUS_LABELS[key]}
|
||||
value={grouped[key].length}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
accent={accent}
|
||||
active={filter === key}
|
||||
onClick={() => setFilter(prev => (prev === key ? 'all' : key))}
|
||||
/>
|
||||
))}
|
||||
<KpiCard
|
||||
label="Quá hạn SLA"
|
||||
value={breachedCount}
|
||||
icon={<AlarmClockOff className="h-4 w-4" />}
|
||||
accent="amberx"
|
||||
active={filter === 'breached'}
|
||||
onClick={() => setFilter(prev => (prev === 'breached' ? 'all' : 'breached'))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Kanban columns — Hồ sơ NS chrome: card-accent rail + header tinted ── */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||
{visibleColumns.map((statusKey) => {
|
||||
const accent = COLUMN_ACCENT[statusKey]
|
||||
const cards = grouped[statusKey].filter(cardMatch)
|
||||
return (
|
||||
<section
|
||||
key={statusKey}
|
||||
className="card-accent flex min-w-0 flex-col overflow-hidden"
|
||||
style={{ ['--accent' as string]: COLUMN_RAIL[accent] }}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-3.5 py-2.5 pl-4">
|
||||
<h3 className={cn('text-sm font-semibold tracking-tight', COLUMN_HEAD[accent])}>
|
||||
{IT_TICKET_STATUS_LABELS[statusKey]}
|
||||
</h3>
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{cards.length}
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2 p-3 pl-4">
|
||||
{list.isLoading && <div className="text-xs text-slate-400">Đang tải...</div>}
|
||||
{!list.isLoading && cards.length === 0 && (
|
||||
<div className="py-4 text-center text-xs italic text-slate-400">Trống</div>
|
||||
)}
|
||||
{cards.map((t) => (
|
||||
<div key={t.id} className="space-y-1 rounded-lg border border-slate-200 bg-white p-2.5 text-xs shadow-sm transition hover:border-slate-300 hover:shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-slate-400">{t.maTicket ?? '—'}</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', IT_TICKET_PRIORITY_BADGE[t.priority])}>
|
||||
{IT_TICKET_PRIORITY_LABELS[t.priority]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate font-semibold text-slate-900">{t.title}</div>
|
||||
<div className="text-slate-500">
|
||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||
<span className="flex min-w-0 items-center gap-1 text-slate-500">
|
||||
<span className="flex min-w-0 items-center gap-1 truncate" title={t.assignedToFullName ?? undefined}>
|
||||
<User className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
{t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||
</span>
|
||||
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
|
||||
{canReassign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDialog(t)}
|
||||
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-violet-50 hover:text-violet-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
|
||||
title="Đổi người xử lý"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{t.slaDueAt && (
|
||||
<span
|
||||
className={cn(
|
||||
'whitespace-nowrap rounded px-1.5 py-0.5 text-[10px]',
|
||||
t.slaBreached ? 'bg-red-100 font-medium text-red-700' : 'bg-slate-100 text-slate-600',
|
||||
)}
|
||||
title={`Hạn xử lý SLA: ${formatSlaDue(t.slaDueAt)}`}
|
||||
>
|
||||
{t.slaBreached ? 'Quá hạn SLA' : `SLA ${formatSlaDue(t.slaDueAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground">
|
||||
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" />
|
||||
Chưa có ticket nào.
|
||||
<div className="card-accent p-8 text-center text-slate-500" style={{ ['--accent' as string]: COLUMN_RAIL.violet }}>
|
||||
<span
|
||||
className="icon-chip mx-auto mb-3"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Ticket className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm">Chưa có ticket nào.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -162,18 +266,18 @@ export function ItTicketsPage() {
|
||||
>
|
||||
{target && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ticket <span className="font-mono">{target.maTicket ?? '—'}</span> · {target.title}
|
||||
<div className="rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
||||
Ticket <span className="font-mono text-slate-700">{target.maTicket ?? '—'}</span> · {target.title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">Người xử lý</label>
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử lý</label>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
|
||||
<option value="">— Chọn người xử lý —</option>
|
||||
{staff.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.fullName}</option>
|
||||
))}
|
||||
</Select>
|
||||
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách…</div>}
|
||||
{staffQ.isLoading && <div className="text-xs text-slate-400">Đang tải danh sách…</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
// Pattern 16-bis 4-place mirror foundation (7× cumulative).
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, Clock, MapPin, Plus, Trash2, Users as UsersIcon, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -259,13 +259,11 @@ export function MeetingCalendarPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
Đặt phòng họp
|
||||
</span>
|
||||
}
|
||||
description={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
|
||||
eyebrow="Văn phòng số"
|
||||
title="Lịch phòng họp"
|
||||
subtitle={bookings.isLoading ? 'Đang tải…' : `${totalThisWeek} booking tuần này`}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
accent="amberx"
|
||||
actions={
|
||||
<Button onClick={openCreateBlank}>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -304,7 +302,10 @@ export function MeetingCalendarPage() {
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div
|
||||
className="card-accent overflow-x-auto"
|
||||
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
|
||||
>
|
||||
<div className="min-w-[900px]">
|
||||
{/* Header row: 7 days */}
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,minmax(0,1fr))] border-b border-slate-200 bg-slate-50">
|
||||
@ -526,52 +527,69 @@ export function MeetingCalendarPage() {
|
||||
>
|
||||
{detailBooking && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{detailBooking.title}</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
|
||||
{statusLabel(detailBooking.status)}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
Người tạo: <span className="font-medium text-slate-700">{detailBooking.bookedByFullName}</span>
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="icon-chip mt-0.5 shrink-0"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold tracking-tight text-brand-800">{detailBooking.title}</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={cn('rounded-full px-2 py-0.5', statusBadgeClass(detailBooking.status))}>
|
||||
{statusLabel(detailBooking.status)}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
Người tạo: <span className="font-medium text-brand-800">{detailBooking.bookedByFullName}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 rounded-md bg-slate-50 p-3 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<MapPin className="h-4 w-4 text-slate-400" />
|
||||
<span>{detailBooking.roomCode} — {detailBooking.roomName}</span>
|
||||
<div className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3.5 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<div className="label-eyebrow flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
Phòng họp
|
||||
</div>
|
||||
<div className="mt-0.5 break-words font-medium text-brand-800">
|
||||
{detailBooking.roomCode} — {detailBooking.roomName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<Clock className="h-4 w-4 text-slate-400" />
|
||||
<span>
|
||||
<div className="min-w-0">
|
||||
<div className="label-eyebrow flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Thời gian
|
||||
</div>
|
||||
<div className="mt-0.5 break-words font-medium text-brand-800">
|
||||
{new Date(detailBooking.startAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
{' – '}
|
||||
{new Date(detailBooking.endAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailBooking.description && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Mô tả</Label>
|
||||
<div className="label-eyebrow">Mô tả</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{detailBooking.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailBooking.note && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Ghi chú</Label>
|
||||
<div className="label-eyebrow">Ghi chú</div>
|
||||
<p className="mt-1 text-sm text-slate-700">{detailBooking.note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<UsersIcon className="h-3.5 w-3.5" />
|
||||
<div className="label-eyebrow flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
Người tham dự ({detailBooking.attendees.length})
|
||||
</Label>
|
||||
</div>
|
||||
{detailBooking.attendees.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-400">— Không có người tham dự —</p>
|
||||
) : (
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
// Pattern 16-bis 4-place mirror foundation (7× cumulative).
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { CalendarDays, MapPin, Pencil, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -126,13 +126,11 @@ export function MeetingRoomsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
Phòng họp
|
||||
</span>
|
||||
}
|
||||
description={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
|
||||
eyebrow="Văn phòng số"
|
||||
title="Phòng họp"
|
||||
subtitle={list.isLoading ? 'Đang tải…' : `${total} phòng họp`}
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
accent="amberx"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -164,17 +162,20 @@ export function MeetingRoomsPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<div
|
||||
className="card-accent overflow-x-auto"
|
||||
style={{ ['--accent' as string]: 'var(--color-amberx-500)' }}
|
||||
>
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Mã</th>
|
||||
<th className="px-3 py-2 text-left">Tên</th>
|
||||
<th className="px-3 py-2 text-left">Sức chứa</th>
|
||||
<th className="px-3 py-2 text-left">Vị trí</th>
|
||||
<th className="px-3 py-2 text-left">Thiết bị</th>
|
||||
<th className="px-3 py-2 text-left">Trạng thái</th>
|
||||
<th className="w-20 px-3 py-2"></th>
|
||||
<thead className="border-b border-slate-100 bg-slate-50/70">
|
||||
<tr className="label-eyebrow">
|
||||
<th className="px-3 py-2.5 pl-5 text-left">Mã</th>
|
||||
<th className="px-3 py-2.5 text-left">Tên</th>
|
||||
<th className="px-3 py-2.5 text-left">Sức chứa</th>
|
||||
<th className="px-3 py-2.5 text-left">Vị trí</th>
|
||||
<th className="px-3 py-2.5 text-left">Thiết bị</th>
|
||||
<th className="px-3 py-2.5 text-left">Trạng thái</th>
|
||||
<th className="w-20 px-3 py-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@ -185,25 +186,45 @@ export function MeetingRoomsPage() {
|
||||
<tr><td colSpan={7} className="p-6 text-center text-slate-400">Chưa có phòng họp — bấm Thêm để tạo mới.</td></tr>
|
||||
)}
|
||||
{filtered.map(row => (
|
||||
<tr key={row.id} className={cn('hover:bg-slate-50', !row.isActive && 'opacity-60')}>
|
||||
<td className="px-3 py-2 font-mono text-xs">{row.code}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-800">{row.name}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-600">{row.capacity} chỗ</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-600">{row.location ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-600">{row.equipment ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.isActive ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">Đang dùng</span>
|
||||
<tr key={row.id} className={cn('transition hover:bg-amberx-50/40', !row.isActive && 'opacity-60')}>
|
||||
<td className="px-3 py-2.5 pl-5 font-mono text-xs text-slate-500">{row.code}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="icon-chip shrink-0"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-amberx-50)', ['--chip-fg' as string]: 'var(--color-amberx-700)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="font-medium text-brand-800">{row.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-xs font-medium tabular-nums text-brand-800">{row.capacity} chỗ</td>
|
||||
<td className="px-3 py-2.5 text-xs text-slate-600">
|
||||
{row.location ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-slate-400" />
|
||||
{row.location}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">Đã tắt</span>
|
||||
<span className="text-slate-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2.5 text-xs text-slate-600">{row.equipment ?? <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{row.isActive ? (
|
||||
<span className="rounded-full bg-greenx-50 px-2 py-0.5 text-[10px] font-medium text-greenx-700">Đang dùng</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500">Đã tắt</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(row)}
|
||||
title="Sửa"
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-brand-600"
|
||||
className="rounded-lg p-1.5 text-slate-500 transition hover:bg-brand-50 hover:text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@ -215,7 +236,7 @@ export function MeetingRoomsPage() {
|
||||
}
|
||||
}}
|
||||
title="Tắt phòng"
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-100 hover:text-red-600"
|
||||
className="rounded-lg p-1.5 text-slate-500 transition hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1 disabled:opacity-50"
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
// Tạo Đề xuất mới — Phase 10.3 G-O3 (S37 2026-05-28).
|
||||
// Form Header card: Title + Description + AmountEstimate + Department + ApprovalWorkflow.
|
||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||
//
|
||||
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader on top + form
|
||||
// grouped into .card-accent sections with .icon-chip headers. Logic (state,
|
||||
// queries, mutation, validation, bindings, submit) UNTOUCHED.
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Save, X } from 'lucide-react'
|
||||
import { FileText, Info, Save, Settings2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -90,10 +94,13 @@ export function ProposalCreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Tạo Đề xuất mới"
|
||||
description="Soạn đề xuất → gửi duyệt theo Quy trình admin config"
|
||||
subtitle="Soạn đề xuất → gửi duyệt theo Quy trình admin config"
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
@ -102,36 +109,62 @@ export function ProposalCreatePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">
|
||||
Tiêu đề <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={300}
|
||||
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{/* Section 1: Nội dung đề xuất */}
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-brand-800">Nội dung đề xuất</h3>
|
||||
</header>
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<Label htmlFor="title">
|
||||
Tiêu đề <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={300}
|
||||
placeholder="vd. Đề xuất mua sắm máy tính cho Phòng IT"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Nội dung chi tiết</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={5000}
|
||||
rows={6}
|
||||
placeholder="Mô tả lý do, nội dung, kết quả mong đợi..."
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Nội dung chi tiết</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={5000}
|
||||
rows={6}
|
||||
placeholder="Mô tả lý do, nội dung, kết quả mong đợi..."
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">{description.length}/5000</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Section 2: Thông tin liên quan */}
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-teal-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-teal-50)', ['--chip-fg' as string]: 'var(--color-teal-700)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-teal-700">Thông tin liên quan</h3>
|
||||
</header>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-5">
|
||||
<div>
|
||||
<Label htmlFor="amount">Số tiền dự kiến (VND)</Label>
|
||||
<Input
|
||||
@ -160,8 +193,21 @@ export function ProposalCreatePage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
{/* Section 3: Quy trình duyệt */}
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-violet-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-violet-700">Quy trình duyệt</h3>
|
||||
</header>
|
||||
<div className="space-y-2 p-5">
|
||||
<Label htmlFor="workflow">
|
||||
Quy trình duyệt <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
@ -182,7 +228,7 @@ export function ProposalCreatePage() {
|
||||
Chỉ hiển thị quy trình loại "Đề xuất" admin đã ghim cho user pick
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate('/proposals')}>
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
// Đề xuất chi tiết — Phase 10.3 G-O3 (S37 2026-05-28).
|
||||
// 3 Section + WorkflowActions buttons + Ý kiến cấp duyệt V2 dynamic.
|
||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||
import { useState } from 'react'
|
||||
//
|
||||
// Re-skin (S69): PURO/Hồ sơ-NS visual language — ui/PageHeader (subject title +
|
||||
// status badge) on top, info sections wrapped in .card-accent cards with
|
||||
// .icon-chip headers, field rows in the Hồ sơ-NS Field idiom (label .label-eyebrow
|
||||
// + value text-brand-800). Logic (queries, mutations, handlers, dialog) UNTOUCHED.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Ban, CheckCircle2, RotateCcw, Send,
|
||||
ArrowLeft, Ban, CheckCircle2, FileText, MessageSquare, Paperclip, RotateCcw, Send,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -39,6 +44,62 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
|
||||
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
|
||||
}
|
||||
|
||||
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
|
||||
function SectionCard({
|
||||
title, icon, accent, head, chipBg, chipFg, count, children,
|
||||
}: {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
accent: string
|
||||
head: string
|
||||
chipBg: string
|
||||
chipFg: string
|
||||
count?: number
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="card-accent" style={{ ['--accent' as string]: accent }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{title}</h3>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Field — label .label-eyebrow style + value text-brand-800 (Hồ sơ-NS idiom).
|
||||
function Field({
|
||||
label, value, mono, full, children,
|
||||
}: {
|
||||
label: string
|
||||
value?: ReactNode
|
||||
mono?: boolean
|
||||
full?: boolean
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const empty = value == null || value === ''
|
||||
return (
|
||||
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
|
||||
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
|
||||
{children ?? (empty ? '—' : value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProposalDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@ -84,17 +145,19 @@ export function ProposalDetailPage() {
|
||||
|
||||
if (proposal.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Đang tải..." />
|
||||
<div className="space-y-5">
|
||||
<PageHeader eyebrow="Văn phòng số" title="Đang tải..." icon={<FileText className="h-5 w-5" />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (proposal.isError || !proposal.data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Lỗi"
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -115,15 +178,27 @@ export function ProposalDetailPage() {
|
||||
const isInWorkflow = status === ProposalStatus.DaGuiDuyet
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title={p.maDeXuat ?? '(Chưa có mã)'}
|
||||
description={p.title}
|
||||
eyebrow={p.maDeXuat ?? 'Đề xuất'}
|
||||
title={p.title}
|
||||
icon={<FileText className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Danh sách
|
||||
</Button>
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
PROPOSAL_STATUS_BADGE[status],
|
||||
)}
|
||||
>
|
||||
{PROPOSAL_STATUS_LABELS[status]}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => navigate('/proposals')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Danh sách
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -176,61 +251,55 @@ export function ProposalDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Section 1: Thông tin */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">1. Thông tin đề xuất</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Tiêu đề</Label>
|
||||
<div className="mt-1 font-medium">{p.title}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Số tiền dự kiến</Label>
|
||||
<div className="mt-1 font-medium tabular-nums">{formatVnd(p.amountEstimate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Phòng ban</Label>
|
||||
<div className="mt-1">{p.departmentName ?? '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Người soạn</Label>
|
||||
<div className="mt-1">{p.drafterFullName ?? '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Quy trình</Label>
|
||||
<div className="mt-1 text-xs">
|
||||
{p.workflowCode ? (
|
||||
<>
|
||||
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
||||
</>
|
||||
) : '— Chưa chọn —'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Ngày tạo</Label>
|
||||
<div className="mt-1 text-xs">{formatDateTime(p.createdAt)}</div>
|
||||
</div>
|
||||
<SectionCard
|
||||
title="Thông tin đề xuất"
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
accent="var(--color-brand-500)"
|
||||
head="text-brand-800"
|
||||
chipBg="var(--color-brand-50)"
|
||||
chipFg="var(--color-brand-600)"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<Field label="Tiêu đề" value={p.title} />
|
||||
<Field label="Số tiền dự kiến" value={formatVnd(p.amountEstimate)} mono />
|
||||
<Field label="Phòng ban" value={p.departmentName ?? '—'} />
|
||||
<Field label="Người soạn" value={p.drafterFullName ?? '—'} />
|
||||
<Field label="Quy trình">
|
||||
{p.workflowCode ? (
|
||||
<span>
|
||||
<span className="font-mono">{p.workflowCode}</span> - {p.workflowName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-300">— Chưa chọn —</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Ngày tạo" value={formatDateTime(p.createdAt)} />
|
||||
{p.description && (
|
||||
<Field label="Nội dung chi tiết" full>
|
||||
<span className="whitespace-pre-wrap font-normal text-slate-700">{p.description}</span>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{p.description && (
|
||||
<div className="pt-3 border-t">
|
||||
<Label className="text-muted-foreground">Nội dung chi tiết</Label>
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm">{p.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 2: Attachments */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">
|
||||
2. File đính kèm <span className="text-muted-foreground text-sm">({p.attachments.length})</span>
|
||||
</h3>
|
||||
<SectionCard
|
||||
title="File đính kèm"
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
accent="var(--color-teal-500)"
|
||||
head="text-teal-700"
|
||||
chipBg="var(--color-teal-50)"
|
||||
chipFg="var(--color-teal-700)"
|
||||
count={p.attachments.length}
|
||||
>
|
||||
{p.attachments.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Chưa có file đính kèm.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{p.attachments.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between rounded border p-2 text-sm">
|
||||
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{a.fileName}</div>
|
||||
<div className="font-medium text-brand-800">{a.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{a.uploadedByFullName} · {(a.fileSize / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
@ -242,11 +311,18 @@ export function ProposalDetailPage() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
|
||||
<SectionCard
|
||||
title="Ý kiến cấp duyệt"
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
accent="var(--color-violet-500)"
|
||||
head="text-violet-700"
|
||||
chipBg="var(--color-violet-50)"
|
||||
chipFg="var(--color-violet-700)"
|
||||
count={p.levelOpinions.length}
|
||||
>
|
||||
{p.levelOpinions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{status === ProposalStatus.Nhap
|
||||
@ -256,20 +332,20 @@ export function ProposalDetailPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{p.levelOpinions.map((o) => (
|
||||
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
|
||||
<div key={o.id} className="rounded-lg border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Bước {o.stepOrder} - {o.stepName} · Cấp {o.levelOrder}
|
||||
</span>
|
||||
<span>{formatDateTime(o.signedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{o.signedByFullName}</div>
|
||||
<div className="mt-1 font-medium text-brand-800">{o.signedByFullName}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Action confirm dialog */}
|
||||
<Dialog
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
// Đề xuất danh sách — Phase 10.3 G-O3 (S37 2026-05-28).
|
||||
// Table 6 cột với Status badge color + filter status + search.
|
||||
// Re-skin S69 (2026-06-17): PURO layout + Hồ sơ Nhân sự visual language, REUSING
|
||||
// the shared ui components (PageHeader + KpiCard). The status filter is rendered
|
||||
// as a ROW of KpiCards (each = one status, value = count, onClick = the EXISTING
|
||||
// setStatus/setInboxOnly setter). Table + pagination + every data hook unchanged.
|
||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import {
|
||||
Plus, Search, FileSignature, FileEdit, SendHorizontal,
|
||||
CheckCircle2, Undo2, XCircle, Layers, Inbox,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { api } from '@/lib/api'
|
||||
@ -59,23 +66,46 @@ export function ProposalsListPage() {
|
||||
const total = list.data?.total ?? 0
|
||||
const totalPages = list.data?.totalPages ?? 1
|
||||
|
||||
const statusOptions: Array<{ value: number | null; label: string }> = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Tất cả' },
|
||||
{ value: 1, label: PROPOSAL_STATUS_LABELS[1] },
|
||||
{ value: 2, label: PROPOSAL_STATUS_LABELS[2] },
|
||||
{ value: 3, label: PROPOSAL_STATUS_LABELS[3] },
|
||||
{ value: 4, label: PROPOSAL_STATUS_LABELS[4] },
|
||||
{ value: 5, label: PROPOSAL_STATUS_LABELS[5] },
|
||||
],
|
||||
[],
|
||||
)
|
||||
// Presentation-only: counts shown on the KpiCard filter chips. Derived from the
|
||||
// currently loaded page (no extra fetch). The active filter's own card shows the
|
||||
// authoritative server `total`; the others show how many of the loaded rows match
|
||||
// — an at-a-glance hint, not a query change.
|
||||
const countByStatus = useMemo(() => {
|
||||
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
|
||||
for (const p of items) acc[p.status] = (acc[p.status] ?? 0) + 1
|
||||
return acc
|
||||
}, [items])
|
||||
|
||||
// Status filter chips (presentation). Mirrors the OLD button row 1:1 — value=null
|
||||
// is "Tất cả" + the five ProposalStatus values, so every filter stays reachable.
|
||||
// Accent per status (playbook): amberx = đã gửi/pending, greenx = đã duyệt, violet
|
||||
// = trả lại/returned, brand = tất cả; nháp + từ chối reuse the nearest tone. The
|
||||
// inbox toggle below is the teal chip.
|
||||
// Count is a presentation hint only: the ACTIVE status card shows the server
|
||||
// `total`; the others show how many of the currently-loaded rows match.
|
||||
const statusCards: Array<{
|
||||
value: number | null
|
||||
label: string
|
||||
icon: ReactNode
|
||||
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
count: number
|
||||
}> = [
|
||||
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: status === null ? total : items.length },
|
||||
{ value: 1, label: PROPOSAL_STATUS_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: status === 1 ? total : countByStatus[1] },
|
||||
{ value: 2, label: PROPOSAL_STATUS_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: status === 2 ? total : countByStatus[2] },
|
||||
{ value: 5, label: PROPOSAL_STATUS_LABELS[5], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: status === 5 ? total : countByStatus[5] },
|
||||
{ value: 3, label: PROPOSAL_STATUS_LABELS[3], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: status === 3 ? total : countByStatus[3] },
|
||||
{ value: 4, label: PROPOSAL_STATUS_LABELS[4], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: status === 4 ? total : countByStatus[4] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số"
|
||||
title="Đề xuất"
|
||||
description="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config"
|
||||
subtitle="Quản lý đề xuất nội bộ — Workflow V2 dynamic theo Quy trình admin config"
|
||||
icon={<FileSignature className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/proposals/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
@ -84,78 +114,80 @@ export function ProposalsListPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value ?? 'all'}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatus(opt.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-sm transition',
|
||||
status === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-input bg-background hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inboxOnly}
|
||||
onChange={(e) => {
|
||||
setInboxOnly(e.target.checked)
|
||||
setPage(1)
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
Inbox duyệt
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Tìm mã hoặc tiêu đề..."
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Status filter — row of KpiCards (PURO). Each wires the EXISTING setter,
|
||||
same semantics as the old button row + inbox checkbox (status and inbox
|
||||
stay independent filter dimensions). */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||
{statusCards.map((c) => (
|
||||
<KpiCard
|
||||
key={c.value ?? 'all'}
|
||||
label={c.label}
|
||||
value={c.count}
|
||||
icon={c.icon}
|
||||
accent={c.accent}
|
||||
active={status === c.value}
|
||||
onClick={() => {
|
||||
setStatus(c.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<KpiCard
|
||||
label="Cần tôi duyệt"
|
||||
value={items.length}
|
||||
icon={<Inbox className="h-4 w-4" />}
|
||||
accent="teal"
|
||||
active={inboxOnly}
|
||||
onClick={() => {
|
||||
setInboxOnly((v) => !v)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
<Search className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Tìm mã hoặc tiêu đề..."
|
||||
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Mã</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tiêu đề</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Trạng thái</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Số tiền dự kiến</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Người soạn</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Ngày tạo</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Mã</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Tiêu đề</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Số tiền dự kiến</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-slate-500">
|
||||
<span
|
||||
className="icon-chip mx-auto mb-2 flex"
|
||||
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Inbox className="h-4 w-4" />
|
||||
</span>
|
||||
Chưa có đề xuất nào.
|
||||
</td>
|
||||
</tr>
|
||||
@ -164,11 +196,22 @@ export function ProposalsListPage() {
|
||||
<tr
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/proposals/${p.id}`)}
|
||||
className="cursor-pointer border-b transition hover:bg-accent/50"
|
||||
className="cursor-pointer border-b border-slate-100 transition last:border-0 hover:bg-brand-50/50"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs">{p.maDeXuat ?? '—'}</td>
|
||||
<td className="px-4 py-2 max-w-md truncate">{p.title}</td>
|
||||
<td className="px-4 py-2">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className="icon-chip h-7! w-7!"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<FileSignature className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-brand-800">{p.maDeXuat ?? '—'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-md truncate px-4 py-2.5 font-medium text-brand-800">{p.title}</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',
|
||||
@ -178,19 +221,19 @@ export function ProposalsListPage() {
|
||||
{PROPOSAL_STATUS_LABELS[p.status as ProposalStatusValue]}
|
||||
</span>
|
||||
{p.currentApprovalLevelOrder && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">Cấp {p.currentApprovalLevelOrder}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">Cấp {p.currentApprovalLevelOrder}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{formatVnd(p.amountEstimate)}</td>
|
||||
<td className="px-4 py-2 text-xs">{p.drafterFullName ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs">{formatDate(p.createdAt)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(p.amountEstimate)}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{p.drafterFullName ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(p.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||
<div className="text-slate-500">
|
||||
{total} đề xuất — Trang {page} / {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
||||
@ -2,15 +2,19 @@
|
||||
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
|
||||
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
|
||||
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
|
||||
// Re-skin S69 (2026-06-17): PURO chrome (ui/PageHeader teal) + Hồ sơ Nhân sự visual
|
||||
// language — accent-rail Card sections + Field idiom + status badge. ALL data logic
|
||||
// (queries / mutations / state / handlers / endpoints) preserved verbatim.
|
||||
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, Plane, RotateCcw, Send,
|
||||
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, GitBranch, Info,
|
||||
MessageSquareText, Plane, RotateCcw, Send, Wallet,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -49,6 +53,71 @@ const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
|
||||
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
|
||||
}
|
||||
|
||||
// ===== Visual language (Hồ sơ Nhân sự idiom): accent map + Card (rail) + Field =====
|
||||
// Accent palettes (teal/violet/amberx/greenx) ship stops 50/100/500/600/700 ONLY — no
|
||||
// -800 — so headings/labels use -700 (brand uses -700 here too; brand-800 reserved for
|
||||
// values). A non-existent stop silently emits no class in Tailwind v4.
|
||||
type Accent = 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
|
||||
const ACCENT: Record<Accent, { chipBg: string; chipFg: string; head: string; rail: string; labelText: string }> = {
|
||||
brand: { chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)', head: 'text-brand-700', rail: 'before:bg-brand-500', labelText: 'text-brand-700' },
|
||||
teal: { chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)', head: 'text-teal-700', rail: 'before:bg-teal-500', labelText: 'text-teal-700' },
|
||||
violet: { chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)', head: 'text-violet-700', rail: 'before:bg-violet-500', labelText: 'text-violet-700' },
|
||||
amberx: { chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)', head: 'text-amberx-700', rail: 'before:bg-amberx-500', labelText: 'text-amberx-700' },
|
||||
greenx: { chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)', head: 'text-greenx-700', rail: 'before:bg-greenx-500', labelText: 'text-greenx-700' },
|
||||
}
|
||||
|
||||
function Card({ title, icon: Icon, action, accent = 'brand', children }: {
|
||||
title: string
|
||||
icon: typeof Info
|
||||
action?: React.ReactNode
|
||||
accent?: Accent
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const a = ACCENT[accent]
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm',
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']", a.rail,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-2.5 pl-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: a.chipBg, ['--chip-fg' as string]: a.chipFg }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className={cn('text-sm font-semibold tracking-tight', a.head)}>{title}</h3>
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
<div className="p-4 pl-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Field — nhãn uppercase accent-tint, value đậm rõ. Empty = dấu —.
|
||||
function Field({ label, value, mono, full, accent = 'brand' }: {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
mono?: boolean
|
||||
full?: boolean
|
||||
accent?: Accent
|
||||
}) {
|
||||
const empty = value == null || value === '' || value === '—'
|
||||
return (
|
||||
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
|
||||
<div className={cn('text-[11px] font-semibold uppercase tracking-wide', ACCENT[accent].labelText)}>{label}</div>
|
||||
<div className={cn('mt-0.5 whitespace-pre-wrap break-words text-sm', empty ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
|
||||
{empty ? '—' : value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const KIND_CONFIG: Record<Kind, {
|
||||
title: string
|
||||
endpoint: string
|
||||
@ -204,17 +273,19 @@ export function WorkflowAppDetailPage() {
|
||||
|
||||
if (detail.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Đang tải..." />
|
||||
<div className="space-y-5">
|
||||
<PageHeader eyebrow="Văn phòng số · Đơn từ" title="Đang tải..." accent="teal" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.isError || !d) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số · Đơn từ"
|
||||
title="Lỗi"
|
||||
accent="teal"
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -222,7 +293,7 @@ export function WorkflowAppDetailPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
Không tải được dữ liệu đơn từ.
|
||||
</div>
|
||||
</div>
|
||||
@ -232,10 +303,13 @@ export function WorkflowAppDetailPage() {
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Văn phòng số · Đơn từ"
|
||||
title={d.maDonTu ?? '(Chưa có mã)'}
|
||||
description={config.title}
|
||||
subtitle={config.title}
|
||||
icon={<Icon className="h-5 w-5" />}
|
||||
accent="teal"
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@ -245,8 +319,8 @@ export function WorkflowAppDetailPage() {
|
||||
/>
|
||||
|
||||
{/* Status row + action buttons */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
|
||||
@ -256,13 +330,13 @@ export function WorkflowAppDetailPage() {
|
||||
{WORKFLOW_APP_STATUS_LABELS[d.status]}
|
||||
</span>
|
||||
{d.currentApprovalLevelOrder != null && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
Cấp hiện tại: <span className="font-semibold text-brand-800">{d.currentApprovalLevelOrder}</span>
|
||||
</span>
|
||||
)}
|
||||
{d.workflowCode && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Quy trình: <span className="font-mono">{d.workflowCode}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
Quy trình: <span className="font-mono text-brand-800">{d.workflowCode}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -270,7 +344,7 @@ export function WorkflowAppDetailPage() {
|
||||
{isDraft && !hasWorkflow && (
|
||||
<>
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
|
||||
value={pickedWorkflowId}
|
||||
onChange={(e) => setPickedWorkflowId(e.target.value)}
|
||||
>
|
||||
@ -320,30 +394,20 @@ export function WorkflowAppDetailPage() {
|
||||
</div>
|
||||
|
||||
{isDraft && !hasWorkflow && (
|
||||
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
||||
<div className="rounded-xl border border-amber-200 bg-amberx-50 p-3 text-sm text-amberx-700">
|
||||
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: Thông tin */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="flex items-center gap-2 font-semibold text-base">
|
||||
<Icon className="h-4 w-4 opacity-70" />
|
||||
1. Thông tin
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<Card title="Thông tin đơn từ" icon={Icon} accent="teal">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
{config.detailFields.map((f) => (
|
||||
<div key={f.label}>
|
||||
<Label className="text-muted-foreground">{f.label}</Label>
|
||||
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
|
||||
</div>
|
||||
<Field key={f.label} label={f.label} value={f.render(d)} accent="teal" />
|
||||
))}
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Ngày tạo</Label>
|
||||
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
|
||||
</div>
|
||||
<Field label="Ngày tạo" value={formatDateTime(d.createdAt)} accent="teal" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Số dư phép (chỉ kind=leave) — Wave 2 hiển thị balance đã embed trong detail */}
|
||||
{kind === 'leave' && d.leaveBalanceRemaining != null && (() => {
|
||||
@ -353,51 +417,39 @@ export function WorkflowAppDetailPage() {
|
||||
const isApproved = d.status === WorkflowAppStatus.DaDuyet
|
||||
const overBudget = remaining < 0 || (!isApproved && remaining < numDays)
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">Số dư phép</h3>
|
||||
<div className="text-sm">
|
||||
Số dư phép năm <span className="font-semibold">{year}</span>:{' '}
|
||||
Được hưởng <span className="font-medium">{d.leaveBalanceEntitled ?? '—'}</span> ·{' '}
|
||||
Đã dùng <span className="font-medium">{d.leaveBalanceUsed ?? '—'}</span> ·{' '}
|
||||
<span className="font-semibold">Còn {remaining}</span> ngày
|
||||
<Card title="Số dư phép" icon={Wallet} accent="greenx">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<Field label={`Được hưởng (${year})`} value={d.leaveBalanceEntitled ?? '—'} accent="greenx" />
|
||||
<Field label="Đã dùng" value={d.leaveBalanceUsed ?? '—'} accent="greenx" />
|
||||
<Field label="Còn lại" value={`${remaining} ngày`} accent="greenx" />
|
||||
</div>
|
||||
{overBudget && (
|
||||
<div className="rounded-lg border border-red-300 bg-amber-50/50 p-3 text-sm font-medium text-amber-900">
|
||||
<div className="mt-3 rounded-lg border border-red-300 bg-amberx-50 p-3 text-sm font-medium text-amberx-700">
|
||||
{remaining < 0
|
||||
? '⚠️ Đã âm số dư phép'
|
||||
: `⚠️ Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
|
||||
? 'Đã âm số dư phép'
|
||||
: `Đơn ${numDays} ngày vượt số dư còn lại (${remaining} ngày)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Section 2: Quy trình duyệt */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Quy trình</Label>
|
||||
<div className="mt-1 text-xs">
|
||||
{d.workflowCode ? (
|
||||
<>
|
||||
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
|
||||
</>
|
||||
) : '— Chưa chọn —'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Cấp hiện tại</Label>
|
||||
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
|
||||
</div>
|
||||
<Card title="Quy trình duyệt" icon={GitBranch} accent="violet">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Quy trình"
|
||||
value={d.workflowCode ? `${d.workflowCode} - ${d.workflowName}` : '— Chưa chọn —'}
|
||||
accent="violet"
|
||||
/>
|
||||
<Field label="Cấp hiện tại" value={d.currentApprovalLevelOrder ?? '—'} accent="violet" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
|
||||
<Card title="Ý kiến cấp duyệt" icon={MessageSquareText} accent="brand">
|
||||
{d.levelOpinions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Chưa có ý kiến.</div>
|
||||
<div className="text-sm text-slate-400">Chưa có ý kiến.</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...d.levelOpinions]
|
||||
@ -405,20 +457,20 @@ export function WorkflowAppDetailPage() {
|
||||
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
|
||||
(a.levelOrder ?? 0) - (b.levelOrder ?? 0))
|
||||
.map((o) => (
|
||||
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div key={o.id} className="rounded-lg border border-slate-200 border-l-4 border-l-greenx-500 bg-greenx-50/40 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>
|
||||
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder}
|
||||
</span>
|
||||
<span>{formatDateTime(o.signedAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{o.signedByFullName}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
|
||||
<div className="mt-1 font-semibold text-brand-800">{o.signedByFullName}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{o.comment ?? '(duyệt — không ý kiến)'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action confirm dialog */}
|
||||
<Dialog
|
||||
@ -439,7 +491,7 @@ export function WorkflowAppDetailPage() {
|
||||
placeholder="Để trống nếu không có ý kiến..."
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{comment.length}/2000</div>
|
||||
<div className="text-xs text-slate-400">{comment.length}/2000</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setActionDialog(null)}>
|
||||
Huỷ
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
// 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 } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
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,
|
||||
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS, WorkflowAppStatus,
|
||||
type PagedResult,
|
||||
} from '@/types/workflowApps'
|
||||
|
||||
@ -78,12 +83,18 @@ 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,
|
||||
@ -92,50 +103,108 @@ export function WorkflowAppsListPage() {
|
||||
|
||||
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-4">
|
||||
<PageHeader title={config.title} description={config.description} />
|
||||
<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"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* 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 bg-muted/50">
|
||||
<thead className="border-b border-slate-200 bg-slate-50">
|
||||
<tr>
|
||||
{config.columns.map((c) => (
|
||||
<th key={c.key} className="px-4 py-2 text-left font-medium">{c.label}</th>
|
||||
<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 text-left font-medium">Trạng thái</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-8 text-center text-muted-foreground">
|
||||
<td colSpan={config.columns.length + 1} className="px-4 py-10 text-center text-slate-400">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
{!list.isLoading && visibleItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<Icon className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
Chưa có dữ liệu.
|
||||
<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>
|
||||
)}
|
||||
{items.map((item: any) => (
|
||||
{visibleItems.map((item: any) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b cursor-pointer hover:bg-muted/40"
|
||||
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">{c.render(item)}</td>
|
||||
<td key={c.key} className="px-4 py-2.5 text-slate-700">{c.render(item)}</td>
|
||||
))}
|
||||
<td className="px-4 py-2">
|
||||
<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',
|
||||
|
||||
Reference in New Issue
Block a user