Files
solution-erp/fe-user/src/components/DataTable.tsx
pqhuy1987 c98030f27c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
[CLAUDE] FE-User: redesign foundation "nâng màu giữ brand" — gradient/accent/badge bắt mắt hơn
Anh: giao diện đơn điệu, muốn đẹp + bắt mắt, font/màu đẹp hơn. Hướng anh chốt
"nâng màu, giữ nền xanh brand" (eoffice trước). Foundation lan tỏa toàn app, KHÔNG
đụng 65 trang lẻ.
- index.css: +accent palette teal/amberx/violet/greenx (đặt tên né trùng Tailwind)
  + utilities .app-gradient-brand / .card-accent / .icon-chip / .stat-value;
  heading 600->700 đậm hơn; .label-eyebrow brand-600. Brand #1F7DC1 + Be Vietnam Pro GIỮ.
- primitives: Button primary/danger gradient nổi bật; Input/Select/Textarea focus-glow
  mạnh hơn; Label brand-600; Dialog title-bar gradient. variant/size keys STABLE.
- shell: Layout stripe dày hơn + logo cap; PageHeader title lớn/đậm + accent bar cao;
  TopBar gradient hairline; DataTable thead gradient brand chữ trắng.
- Dashboard: KPI cards accent + icon chips.
- color maps (contract/PE phase + PE display status): -700->-800 đậm chữ, phase nháp tint brand.

Visual-only — props/handler/signature nguyên. Build PASS (tsc -b 0 error). a11y:
contrast AA + prefers-reduced-motion. fe-admin mirror đợt sau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:12:40 +07:00

203 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/cn'
export type Column<T> = {
key: string
header: ReactNode
render: (row: T) => ReactNode
sortable?: boolean
width?: string
align?: 'left' | 'center' | 'right'
}
// Always-visible row-action button (NAMGROUP convention: NEVER hide actions
// behind opacity-0 group-hover — touch devices can't reveal them). 7×7 icon
// button, tone-tinted hover. Wrap an action cell with <RowActions> to stop the
// row's onClick from firing when a button is pressed.
type RowActionTone = 'default' | 'brand' | 'danger' | 'success'
const ROW_ACTION_TONE: Record<RowActionTone, string> = {
default: 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
brand: 'text-slate-500 hover:bg-brand-50 hover:text-brand-700',
danger: 'text-slate-500 hover:bg-red-50 hover:text-red-600',
success: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-600',
}
export function RowActions({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn('flex items-center justify-end gap-0.5', className)}
onClick={e => e.stopPropagation()}
>
{children}
</div>
)
}
export function RowActionButton({
tone = 'default',
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: RowActionTone }) {
return (
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60',
'disabled:pointer-events-none disabled:opacity-40',
ROW_ACTION_TONE[tone],
className,
)}
{...props}
/>
)
}
type Props<T> = {
columns: Column<T>[]
rows: T[]
getRowKey: (row: T) => string
isLoading?: boolean
empty?: ReactNode
sortBy?: string
sortDesc?: boolean
onSortChange?: (sortBy: string, sortDesc: boolean) => void
onRowClick?: (row: T) => void
}
export function DataTable<T>({
columns,
rows,
getRowKey,
isLoading,
empty,
sortBy,
sortDesc,
onSortChange,
onRowClick,
}: Props<T>) {
return (
// rounded-lg + crisp border (no decorative shadow).
<div className="overflow-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full text-[12px]">
{/* 2026-06-16 anh "thead nền màu brand đậm hơn": solid brand gradient +
white uppercase — đậm nhận diện rõ rệt (was pale brand-50/60). White
on brand-600→700 = >7:1 contrast. */}
<thead className="app-gradient-brand sticky top-0 z-10 text-white">
<tr>
{columns.map(c => (
<th
key={c.key}
className={cn(
'px-3 py-2.5 text-[11px] font-bold uppercase tracking-wider',
c.align === 'right' && 'text-right',
c.align === 'center' && 'text-center',
c.align !== 'right' && c.align !== 'center' && 'text-left',
c.width,
)}
>
{c.sortable && onSortChange ? (
<button
onClick={() => onSortChange(c.key, sortBy === c.key ? !sortDesc : false)}
className="inline-flex items-center gap-1 transition-colors hover:text-white/80"
>
{c.header}
{sortBy === c.key && (sortDesc ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />)}
</button>
) : (
c.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<tr key={`sk-${i}`} className="border-t border-slate-100">
{columns.map(c => (
<td key={c.key} className="px-3 py-2.5">
<div className="h-3 animate-pulse rounded bg-slate-100" />
</td>
))}
</tr>
))}
{!isLoading && rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-12 text-center text-[13px] text-slate-400">
{empty ?? 'Không có dữ liệu'}
</td>
</tr>
)}
{!isLoading &&
rows.map(row => (
<tr
key={getRowKey(row)}
className={cn(
'border-t border-slate-100 text-slate-700 transition-colors',
onRowClick && 'cursor-pointer hover:bg-brand-50/40',
)}
onClick={() => onRowClick?.(row)}
>
{columns.map(c => (
<td
key={c.key}
className={cn(
'px-3 py-2',
c.align === 'right' && 'text-right',
c.align === 'center' && 'text-center',
)}
>
{c.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
type PaginationProps = {
page: number
pageSize: number
total: number
onChange: (page: number) => void
}
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const from = total === 0 ? 0 : (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, total)
return (
<div className="flex items-center justify-between py-3 text-[12px] text-slate-500">
<span className="tabular-nums">
{from}{to} / {total}
</span>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white"
>
Trước
</button>
<span className="px-2 tabular-nums">
Trang {page}/{totalPages}
</span>
<button
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white"
>
Sau
</button>
</div>
</div>
)
}