[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

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:
pqhuy1987
2026-06-17 09:57:46 +07:00
parent a8bbdaeeea
commit c556f6cfa2
22 changed files with 1907 additions and 929 deletions

View File

@ -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>

View File

@ -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 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 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ử </label>
<label className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">Người xử </label>
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
<option value=""> Chọn người xử </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>
)}

View File

@ -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"> tả</Label>
<div className="label-eyebrow"> 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 người tham dự </p>
) : (

View File

@ -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"></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"></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 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" />

View File

@ -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')}>

View File

@ -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 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

View File

@ -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"></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"></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 đ 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">

View File

@ -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ố phép</h3>
<div className="text-sm">
Số 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 ý kiến.</div>
<div className="text-sm text-slate-400">Chưa ý 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ỷ

View File

@ -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 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',