Files
HMDesign/src/patterns/dashboard-layout.stories.tsx
T

474 lines
20 KiB
TypeScript

import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import { DashboardScreen } from "./dashboard-composition"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DashboardContent,
DashboardLayout,
DashboardHeader,
DashboardSidebar,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
DashboardMain,
DataTable,
DataTableColumnHeader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
dataTableSelectColumn,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { formatDate } from "./sample-users"
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
import "./dashboard-layout.stories.css"
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
const photo =
"data:image/svg+xml;utf8," +
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
const profileData = {
name: "김한맥",
description: "프로젝트 관리자",
photo,
fallback: "김",
}
const profileActions: { label: string; destructive?: boolean }[] = [
{ label: "프로필 설정" },
{ label: "로그아웃", destructive: true },
]
function ProfileAvatar() {
return (
<Avatar>
<AvatarImage src={profileData.photo} alt="" />
<AvatarFallback>{profileData.fallback}</AvatarFallback>
</Avatar>
)
}
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
return (
<DropdownMenu modal={modal}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
{profileActions.map((action) => (
<Fragment key={action.label}>
{action.destructive ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
type Mode = "compact" | "sidebar" | "header-sidebar"
type StatusFilter = DashboardWorkFilter
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
const filterButton = (filter: (typeof statusFilters)[number]) => (
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
{filter.label} <strong>{count(filter.id)}</strong>건
</Button>
)
const [all, ...rest] = statusFilters
const workStates = rest.filter((filter) => filter.id !== "issue")
const issue = rest.find((filter) => filter.id === "issue")!
return (
<DashboardHeader data-story-slot="compact-header">
<div data-story-slot="compact-brand">
<strong>프로젝트 대시보드</strong>
</div>
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
<span data-story-slot="compact-context">업무</span>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(all)}
<span data-story-slot="compact-divider" aria-hidden="true" />
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(issue)}
</div>
</DashboardHeader>
)
}
// Dashboard internal menu; kept at every viewport width.
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
const localNav = (thirdLevel?: boolean): LocalNode[] => [
{ id: "overview", label: "개요", icon: LayoutGridIcon },
{
id: "projects",
label: "업무 관리",
children: [
thirdLevel
? {
id: "projects/tasks",
label: "업무 현황",
children: [
{ id: "projects/tasks/all", label: "전체 업무" },
{ id: "projects/tasks/mine", label: "담당 업무" },
{ id: "projects/tasks/delayed", label: "지연 업무" },
],
}
: { id: "projects/tasks", label: "업무 현황" },
{ id: "projects/schedule", label: "일정" },
],
},
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
{ id: "settings", label: "설정", icon: SettingsIcon },
]
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
)
const columns: DataTableColumnDef<DashboardWork>[] = [
dataTableSelectColumn<DashboardWork>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
{ accessorKey: "department", header: "부서" },
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
cell: ({ row }) => formatDate(row.original.createdAt),
},
]
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const total = table.getFilteredRowModel().rows.length
return (
<Card>
<CardHeader>
<CardTitle>업무 목록</CardTitle>
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<FilterBar>
<FilterBarSearch
value={search}
onValueChange={(value) => {
setSearch(value)
table.setGlobalFilter(value || undefined)
}}
placeholder="업무명·부서 검색"
/>
{compact ? null : <Select
value={status}
onValueChange={(value) => {
setStatus(value)
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
}}
>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
</SelectContent>
</Select>}
<FilterBarActions>
<Button
type="button"
variant="cancel"
appearance="solid"
onClick={() => {
setSearch("")
setStatus("all")
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
onReset?.()
}}
>
초기화
</Button>
</FilterBarActions>
</FilterBar>
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
</div>
</CardContent>
</Card>
)
}
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
const kpis = statusFilters.map(({ id, label }) => {
const value = countDashboardWorkFilter(data, id)
const progress = data.length ? Math.round(value / data.length * 100) : 0
return { id, label, value, progress }
})
return (
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
{kpis.map((kpi) => (
<Card key={kpi.label}>
<CardHeader>
<CardDescription>{kpi.label}</CardDescription>
{loading ? null : (
<CardAction>
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
</CardAction>
)}
</CardHeader>
<CardContent>
{loading ? (
<div className="grid gap-3">
<Skeleton style={{ height: 36, width: 80 }} />
<Skeleton style={{ height: 8 }} />
</div>
) : (
<div className="grid gap-3">
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}
function Screen({
mode = "compact",
data = dashboardWorks,
loading,
longTitle,
wide,
thirdLevel,
}: {
mode?: Mode
data?: DashboardWork[]
loading?: boolean
longTitle?: boolean
wide?: boolean
thirdLevel?: boolean
}) {
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
const compact = mode === "compact"
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
const nav = localNav(thirdLevel)
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<Collapsible
key={node.id}
data-current={isCurrent(node.id) ? "" : undefined}
open={openGroups[node.id] ?? true}
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
>
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
</Collapsible>
) : (
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</a></Button>
)
)
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
const sidebar = (
<DashboardSidebar>
<strong>프로젝트 대시보드</strong>
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
<ProfileMenu side="right" align="end" trigger={
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
<ProfileAvatar />{profileData.name}
</Button>
} />
</DashboardSidebar>
)
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
const compactMain = (
<DashboardMain id="dashboard-compact-results">
<PageHeader>
<PageHeaderContent>
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
</PageHeaderContent>
</PageHeader>
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
</DashboardMain>
)
const main = compact ? compactMain : (
<DashboardMain>
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>업무 현황</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>{title}</PageHeaderTitle>
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button type="button" variant="cancel" appearance="solid">
다운로드
</Button>
<Button type="button" variant="primary">
신규 등록
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
<DropdownMenuItem>보관</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeaderActions>
</PageHeader>
<DashboardContent>
<Kpis data={data} loading={loading} />
<TaskList data={resultData} loading={loading} />
{wide ? (
<Card>
<CardHeader>
<CardTitle>넓은 내용</CardTitle>
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ overflowX: "auto" }}>
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
</div>
</CardContent>
</Card>
) : null}
</DashboardContent>
</DashboardMain>
)
return (
<DashboardLayout
className={compact ? "dashboard-story dashboard-story-compact" : "dashboard-story"}
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
sidebar={compact ? undefined : sidebar}
>
{main}
</DashboardLayout>
)
}
const frame = (height?: number): CSSProperties | undefined => (height ? ({ "--hm-dashboard-height": `${height}px` } as CSSProperties) : undefined)
// Shared by Storybook and the standalone Vite export. Keeping the representative
// story composition here prevents the export from becoming a second hand-written UI.
export function DashboardAll() {
return (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Dashboard"
description="업무 화면의 전체 틀입니다. DASHBOARD 단일 모드로, 내부 48px Compact Header와 상태 필터 바 아래에 콘텐츠를 바로 잇습니다."
/>
<StorySection
title="Overview"
description="Compact Header(48px) · 상태 필터 · 콘텐츠. 최소 폭 960px을 유지하며 좁은 화면에서는 Dashboard 전체를 가로 스크롤합니다."
>
<DashboardScreen mode="compact" />
</StorySection>
</StoryPage>
)
}
// Menu label is Dashboard; the fixed id keeps the existing story IDs (patterns-layout-dashboard-layout--*) used by links and QA inventory.
const meta = { id: "patterns-layout-dashboard-layout", title: "Patterns/Layout/Dashboard", excludeStories: ["DashboardAll"], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Representative Story reproduces the ui_viz Variant C layout (48px Compact Header, centred status bar, content) with HM parts.
// Operating DASHBOARD Mode has no Sidebar; retained Sidebar examples are compatibility verification only.
// The page heading and Dashboard's native minimum-width scroll frame share the common Inner.
export const All: Story = {
name: "Dashboard",
render: () => <DashboardAll />,
}
// Keep existing Sidebar coverage and Story IDs; these are not operating references.
function SidebarVerification({ children }: { children: ReactNode }) {
return <>
<p role="note">VERIFICATION ONLY — 운영 사용 금지</p>
{children}
</>
}
export const WithSidebar: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Header + Sidebar", render: () => <SidebarVerification><Screen mode="header-sidebar" /></SidebarVerification> }
export const ThirdLevel: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Third Level", render: () => <SidebarVerification><Screen mode="sidebar" thirdLevel /></SidebarVerification> }
export const SidebarOnly: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Sidebar", render: () => <SidebarVerification><Screen mode="sidebar" /></SidebarVerification> }
export const LongContent: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Long Content", render: () => <SidebarVerification><Screen mode="sidebar" longTitle wide /></SidebarVerification> }
export const Loading: Story = { tags: ["verification"], render: () => <Screen loading /> }
export const Empty: Story = { tags: ["verification"], render: () => <Screen data={[]} /> }
export const Framed: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Framed", render: () => <SidebarVerification><div style={frame(640)}><Screen mode="sidebar" /></div></SidebarVerification> }