Drop the category kicker from the 83 sidebar Stories (kicker is now optional; hidden verification Stories keep it) and shorten or remove repeated, obvious or internal page/section descriptions. Required specs, limits, virtual-data and undecided-value notices stay. Story IDs, exports, fixtures, shared state notes and package code are unchanged. Record: docs/reports/storybook-ui-simplification-2026-10-22.md
473 lines
20 KiB
TypeScript
473 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"
|
|
description="DASHBOARD 단일 모드 · 48px Compact Header + 상태 필터 + 콘텐츠"
|
|
/>
|
|
<StorySection
|
|
title="Overview"
|
|
description="최소 폭 960px · 좁으면 가로 스크롤"
|
|
>
|
|
<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> }
|