[CLAUDE] FE: Văn phòng số foundation — shared PageHeader/KpiCard/WidgetCard + Dashboard landing (PURO · CSS Hồ sơ NS) + sync fe-admin index.css + menu Off_Dashboard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s

- 3 shared component PageHeader/KpiCard/WidgetCard ×2 app SHA256-identical; tái dùng token Hồ sơ NS (.app-gradient-brand/.card-accent/.icon-chip/.stat-value/.label-eyebrow + accent palette teal/violet/amberx/greenx); gotcha #66 text-white! trên gradient header.
- OfficeDashboardPage 2-cột widget kiểu PURO HomePage: Đề xuất/Đơn từ/Ticket/Phòng họp hôm nay + "Công việc của tôi" + Thao tác nhanh. Reuse query endpoint sẵn có (shared TanStack cache, KHÔNG BE/API mới), đếm client-side, loading/error/empty mỗi widget.
- Sync fe-admin/src/index.css ← fe-user (đóng drift S66-S68: heading 600→700, ink #0f172a→#0b1220, label-eyebrow slate→brand-600 + rule gotcha #66). Nay 2 app đồng bộ.
- Menu key Off_Dashboard (MenuKeys.cs const + All[] + DbInitializer seed Order 0 dưới Off) — no migration, idempotent. GIỮ ẨN non-Admin (RevokeTemporarilyHiddenModules StartsWith Off). Chưa golive.
- Wire 4-place ×2 app: App.tsx route /office/dashboard + menuKeys.ts + Layout staticMap.
- Fix KpiCard activeBorder -300 → -500 (accent palette chỉ 50/100/500/600/700 — chống "vỡ màu im lặng" Tailwind v4: border-teal-300 rơi default Tailwind, border-amberx-300 drop hẳn).
- Build PASS x2 (fe-user index-DrxDysO7 / fe-admin index-TbkadgKd) + dotnet slnx 0/0. reviewer PASS 0 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 09:24:17 +07:00
parent 764fe7024b
commit a8bbdaeeea
21 changed files with 1821 additions and 23 deletions

View File

@ -0,0 +1,474 @@
// Bảng điều khiển Văn phòng số (E-Office) — landing dashboard, PURO HomePage style.
// Composes the 3 shared ui widgets (PageHeader / KpiCard / WidgetCard) over the
// EXISTING data hooks of the four E-Office modules. NO new API calls, NO new BE:
// every query below mirrors the queryKey + endpoint already used by the module
// pages, so the TanStack cache is shared and counts are computed client-side.
//
// • Đề xuất → GET /proposals (ProposalsListPage)
// • Đơn từ → GET /leave|ot|travel-requests (WorkflowAppsListPage KIND_CONFIG)
// • Ticket CNTT → GET /it-tickets (ItTicketsPage)
// • Phòng họp → GET /meeting-bookings (MeetingCalendarPage)
//
// Layout: PageHeader (brand) on top, then a 2-col grid — LEFT (~2/3) a stack of
// WidgetCards, RIGHT (~1/3) a "Công việc của tôi" panel + quick-action buttons.
// Stacks to 1 column under lg. Each widget's onExpand navigates to its real route.
// gotcha 66: any heading on the brand gradient lives inside WidgetCard, which
// already uses text-white! — this page adds no gradient headings of its own.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
CalendarDays,
ClipboardList,
FilePlus2,
FileSignature,
Inbox,
LayoutDashboard,
ListChecks,
Plus,
Ticket,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { KpiCard } from '@/components/ui/KpiCard'
import { WidgetCard } from '@/components/ui/WidgetCard'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { ProposalStatus, type PagedResult, type ProposalListItemDto } from '@/types/proposal'
import {
IT_TICKET_STATUS_LABELS,
ItTicketStatus,
WorkflowAppStatus,
type ItTicketDto,
type LeaveRequestDto,
type OtRequestDto,
type TravelRequestDto,
} from '@/types/workflowApps'
import type { MeetingBookingDto } from '@/types/meeting'
// ── date window for "today" meeting bookings (local midnight → next midnight) ──
function todayWindow(): { start: string; end: string } {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
return { start: start.toISOString(), end: end.toISOString() }
}
function countByStatus<T extends { status: number }>(items: T[], status: number): number {
return items.reduce((n, x) => (x.status === status ? n + 1 : n), 0)
}
// A small skeleton body used while a widget's data is loading. Mimics a couple of
// stat rows so the card keeps its height (no layout shift on resolve).
function WidgetSkeleton() {
return (
<div className="space-y-2.5" aria-hidden>
<div className="h-3.5 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-1/2 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3.5 w-3/5 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
</div>
)
}
// Inline error body — graceful, never blocks the page.
function WidgetError({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center">
<span
className="icon-chip"
style={{ ['--chip-bg' as string]: 'var(--color-accent-500)', ['--chip-fg' as string]: '#fff' }}
aria-hidden
>
<AlertTriangle className="h-4 w-4" />
</span>
<p className="text-xs text-slate-500">Không tải đưc dữ liệu.</p>
<button
type="button"
onClick={onRetry}
className="rounded-md px-2 py-1 text-xs font-medium text-brand-600 transition hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
Thử lại
</button>
</div>
)
}
// A labelled metric line inside a widget body. `tone` tints the value; clickable
// rows expose a button affordance (used to deep-link a filtered list view).
function MetricRow({
label,
value,
tone,
onClick,
}: {
label: string
value: number
tone: string
onClick?: () => void
}) {
const clickable = !!onClick
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.()
}
}
: undefined
}
className={cn(
'flex items-center justify-between gap-3 rounded-lg px-2.5 py-1.5 transition',
clickable &&
'cursor-pointer hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
)}
>
<span className="text-[13px] text-slate-600">{label}</span>
<span className={cn('stat-value text-base', tone)}>{value}</span>
</div>
)
}
export function OfficeDashboardPage() {
const navigate = useNavigate()
const { start, end } = useMemo(todayWindow, [])
// ── Đề xuất ── same queryKey/endpoint as ProposalsListPage (shared cache).
// First page, large size: enough to count the active workload client-side.
const proposalsQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: false, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { page: 1, pageSize: 100 } })).data,
})
// "Cần duyệt" = items in MY approval inbox (BE inboxOnly filter — the real
// needs-my-action signal). Mirrors the inbox toggle on ProposalsListPage.
const proposalsInboxQ = useQuery({
queryKey: ['proposals', { status: null, inboxOnly: true, search: '', page: 1, dashboard: true }],
queryFn: async () =>
(await api.get<PagedResult<ProposalListItemDto>>('/proposals', { params: { inboxOnly: true, page: 1, pageSize: 100 } }))
.data,
})
// ── Đơn từ ── three endpoints from WorkflowAppsListPage KIND_CONFIG.
const leaveQ = useQuery({
queryKey: ['/leave-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<LeaveRequestDto>>('/leave-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const otQ = useQuery({
queryKey: ['/ot-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<OtRequestDto>>('/ot-requests', { params: { page: 1, pageSize: 50 } })).data,
})
const travelQ = useQuery({
queryKey: ['/travel-requests', { page: 1 }],
queryFn: async () => (await api.get<PagedResult<TravelRequestDto>>('/travel-requests', { params: { page: 1, pageSize: 50 } })).data,
})
// ── Ticket CNTT ── same queryKey/endpoint as ItTicketsPage (shared cache).
const ticketsQ = useQuery({
queryKey: ['it-tickets'],
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
})
// ── Phòng họp hôm nay ── /meeting-bookings windowed to today (no room filter).
const meetingsTodayQ = useQuery({
queryKey: ['meeting-bookings', { dashboard: 'today', start }],
queryFn: async () =>
(await api.get<MeetingBookingDto[]>('/meeting-bookings', { params: { startDate: start, endDate: end } })).data,
})
// ── derived counts (client-side) ──
const proposals = proposalsQ.data?.items ?? []
const proposalTotal = proposalsQ.data?.total ?? proposals.length
const proposalPending = countByStatus(proposals, ProposalStatus.DaGuiDuyet)
const proposalInbox = proposalsInboxQ.data?.total ?? (proposalsInboxQ.data?.items.length ?? 0)
const donTu = useMemo(
() => [...(leaveQ.data?.items ?? []), ...(otQ.data?.items ?? []), ...(travelQ.data?.items ?? [])],
[leaveQ.data, otQ.data, travelQ.data],
)
const donTuSubmitted = countByStatus(donTu, WorkflowAppStatus.DaGuiDuyet)
const donTuReturned = countByStatus(donTu, WorkflowAppStatus.TraLai)
const donTuApproved = countByStatus(donTu, WorkflowAppStatus.DaDuyet)
const donTuLoading = leaveQ.isLoading || otQ.isLoading || travelQ.isLoading
const donTuError = leaveQ.isError || otQ.isError || travelQ.isError
const tickets = ticketsQ.data?.items ?? []
const ticketOpen = countByStatus(tickets, ItTicketStatus.Open)
const ticketInProgress = countByStatus(tickets, ItTicketStatus.InProgress)
const ticketBreached = tickets.reduce((n, t) => (t.slaBreached ? n + 1 : n), 0)
const meetingsToday = meetingsTodayQ.data ?? []
const meetingsTodayCount = meetingsToday.length
// "Công việc của tôi" — total items currently awaiting THIS user's action across
// the modules. Proposals come from the BE inbox filter; đơn-từ in "Đã gửi duyệt"
// are pending approval. (Tickets have their own assignment flow, surfaced in
// their widget rather than double-counted here.)
const myTodo = proposalInbox + donTuSubmitted
return (
<div className="p-6">
<PageHeader
eyebrow="Văn phòng số"
title="Bảng điều khiển"
subtitle="Tổng quan đề xuất, đơn từ, ticket và lịch họp trong ngày"
icon={<LayoutDashboard className="h-5 w-5" />}
accent="brand"
/>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{/* ───────────────────────── LEFT (~2/3) — widget stack ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-2">
{/* Đề xuất */}
<WidgetCard
title="Đề xuất"
icon={<FileSignature className="h-4 w-4" />}
accent="brand"
onExpand={() => navigate('/proposals')}
onRefresh={() => {
proposalsQ.refetch()
proposalsInboxQ.refetch()
}}
empty={!proposalsQ.isLoading && !proposalsQ.isError && proposalTotal === 0}
emptyText="Chưa có đề xuất nào."
>
{proposalsQ.isError ? (
<WidgetError onRetry={() => proposalsQ.refetch()} />
) : proposalsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Cần duyệt"
value={proposalInbox}
icon={<Inbox className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<KpiCard
label="Chờ duyệt"
value={proposalPending}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate(`/proposals?status=${ProposalStatus.DaGuiDuyet}`)}
/>
<KpiCard
label="Tất cả"
value={proposalTotal}
icon={<ListChecks className="h-4 w-4" />}
accent="teal"
onClick={() => navigate('/proposals')}
/>
</div>
)}
</WidgetCard>
{/* Đơn từ (nghỉ phép / OT / công tác) */}
<WidgetCard
title="Đơn từ"
icon={<FileSignature className="h-4 w-4" />}
accent="teal"
onExpand={() => navigate('/workflow-apps/leave')}
onRefresh={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
empty={!donTuLoading && !donTuError && donTu.length === 0}
emptyText="Chưa có đơn từ nào."
>
{donTuError ? (
<WidgetError
onRetry={() => {
leaveQ.refetch()
otQ.refetch()
travelQ.refetch()
}}
/>
) : donTuLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label="Đã gửi duyệt"
value={donTuSubmitted}
accent="amberx"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Trả lại"
value={donTuReturned}
accent="violet"
onClick={() => navigate('/workflow-apps/leave')}
/>
<KpiCard
label="Đã duyệt"
value={donTuApproved}
accent="greenx"
onClick={() => navigate('/workflow-apps/leave')}
/>
</div>
)}
</WidgetCard>
{/* Ticket CNTT */}
<WidgetCard
title="Ticket CNTT"
icon={<Ticket className="h-4 w-4" />}
accent="violet"
onExpand={() => navigate('/it-tickets')}
onRefresh={() => ticketsQ.refetch()}
empty={!ticketsQ.isLoading && !ticketsQ.isError && tickets.length === 0}
emptyText="Chưa có ticket nào."
>
{ticketsQ.isError ? (
<WidgetError onRetry={() => ticketsQ.refetch()} />
) : ticketsQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.Open]}
value={ticketOpen}
icon={<Inbox className="h-4 w-4" />}
accent="violet"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label={IT_TICKET_STATUS_LABELS[ItTicketStatus.InProgress]}
value={ticketInProgress}
icon={<ClipboardList className="h-4 w-4" />}
accent="brand"
onClick={() => navigate('/it-tickets')}
/>
<KpiCard
label="Quá hạn SLA"
value={ticketBreached}
icon={<AlertTriangle className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/it-tickets')}
/>
</div>
)}
</WidgetCard>
{/* Phòng họp hôm nay */}
<WidgetCard
title="Phòng họp hôm nay"
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onExpand={() => navigate('/meeting-calendar')}
onRefresh={() => meetingsTodayQ.refetch()}
empty={!meetingsTodayQ.isLoading && !meetingsTodayQ.isError && meetingsTodayCount === 0}
emptyText="Hôm nay chưa có lịch họp."
>
{meetingsTodayQ.isError ? (
<WidgetError onRetry={() => meetingsTodayQ.refetch()} />
) : meetingsTodayQ.isLoading ? (
<WidgetSkeleton />
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<KpiCard
label="Lịch họp hôm nay"
value={meetingsTodayCount}
icon={<CalendarDays className="h-4 w-4" />}
accent="amberx"
onClick={() => navigate('/meeting-calendar')}
/>
</div>
{/* A compact peek of the next few bookings today. */}
<ul className="divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200">
{meetingsToday
.slice()
.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime())
.slice(0, 4)
.map((b) => (
<li key={b.id} className="flex items-center justify-between gap-3 px-3 py-2 text-xs">
<span className="min-w-0 truncate font-medium text-slate-700">{b.title}</span>
<span className="shrink-0 tabular-nums text-slate-500">
{new Date(b.startAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })} ·{' '}
{b.roomCode}
</span>
</li>
))}
</ul>
</div>
)}
</WidgetCard>
</div>
{/* ───────────────────────── RIGHT (~1/3) — my work + actions ───────────────────────── */}
<div className="flex flex-col gap-5 lg:col-span-1">
{/* Công việc của tôi */}
<WidgetCard
title="Công việc của tôi"
icon={<ListChecks className="h-4 w-4" />}
accent="brand"
empty={false}
>
{proposalsInboxQ.isError ? (
<WidgetError onRetry={() => proposalsInboxQ.refetch()} />
) : (
<div className="space-y-3">
<div className="flex items-end justify-between gap-3 rounded-xl bg-brand-50 px-3.5 py-3">
<div>
<div className="label-eyebrow">Cần xử </div>
<p className="mt-0.5 text-[13px] text-slate-600">Mục đang chờ thao tác của bạn</p>
</div>
<div className="stat-value text-3xl text-brand-800">
{proposalsInboxQ.isLoading || donTuLoading ? '—' : myTodo}
</div>
</div>
<div className="space-y-1">
<MetricRow
label="Đề xuất chờ tôi duyệt"
value={proposalsInboxQ.isLoading ? 0 : proposalInbox}
tone="text-brand-800"
onClick={() => navigate('/proposals?inboxOnly=true')}
/>
<MetricRow
label="Đơn từ đã gửi duyệt"
value={donTuLoading ? 0 : donTuSubmitted}
tone="text-teal-700"
onClick={() => navigate('/workflow-apps/leave')}
/>
<MetricRow
label="Ticket đang mở"
value={ticketsQ.isLoading ? 0 : ticketOpen}
tone="text-violet-700"
onClick={() => navigate('/it-tickets')}
/>
</div>
</div>
)}
</WidgetCard>
{/* Thao tác nhanh */}
<section className="card-accent flex flex-col gap-2 p-4" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
<h3 className="mb-1 text-sm font-semibold tracking-tight text-slate-800">Thao tác nhanh</h3>
<Button variant="primary" className="justify-start" onClick={() => navigate('/proposals/new')}>
<FilePlus2 className="h-4 w-4" />
Tạo đ xuất
</Button>
<Button variant="secondary" className="justify-start" onClick={() => navigate('/workflow-apps/leave')}>
<FileSignature className="h-4 w-4" />
Tạo đơn
</Button>
<Button variant="outline" className="justify-start" onClick={() => navigate('/it-tickets')}>
<Plus className="h-4 w-4" />
Tạo ticket
</Button>
</section>
</div>
</div>
</div>
)
}