Files
HMDesign/src/patterns/dashboard-layout.stories.tsx
T
shkim4 4c29894e56 refactor(storybook): simplify component and preview examples
Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
2026-10-23 00:28:05 +09:00

467 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 { 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 title="Dashboard" />
<StorySection title="Overview">
<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> }