feat: integrate latest HMDesign design system updates
This commit is contained in:
1 parent
99faec189e
commit
1c4e0bb9a1
209 files changed
+8845
-552
No files matched your search
@@ -0,0 +1,205 @@
|
||||
import { useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react"
|
||||
import { AlertCircle, ArrowUpRight, CalendarDays, CheckCheck, ClipboardList, Clock3, Plus, Search } from "lucide-react"
|
||||
import {
|
||||
Avatar, AvatarFallback, Badge, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,
|
||||
GNB, GNBBrand, GNBNav, GNBMobileMenu, GNBMobileLink, GNBUtilities, GNBUser,
|
||||
NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink,
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuItem,
|
||||
DataTable, DataTableColumnHeader, DatePicker, dataTableSelectColumn, useDataTable,
|
||||
Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
FilterBar, FilterBarActions, FilterBarSearch, Input, Label,
|
||||
PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,
|
||||
Progress, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea,
|
||||
countDashboardFilter, dashboardFilterDefinitions, dashboardFilterIds, dashboardStatusDefinitions, matchesDashboardFilter,
|
||||
type DashboardFilterId, type DashboardStatusId, type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
import { departments, loadWorks, projects, storageKey, type Work } from "./data"
|
||||
// GNB_ONLY is requested by the user. Dashboard work status remains a content filter, separate from navigation.
|
||||
|
||||
type Conditions = { query: string; department: string }
|
||||
const emptyConditions: Conditions = { query: "", department: "all" }
|
||||
const sections = [
|
||||
{ id: "overview", label: "대시보드" },
|
||||
{ id: "work-results", label: "업무 목록" },
|
||||
{ id: "project-overview", label: "프로젝트" },
|
||||
{ id: "work-deadlines", label: "마감 일정" },
|
||||
]
|
||||
|
||||
function statusBadges(work: Work) {
|
||||
return <span className="work-badges">
|
||||
<Badge variant="outline" data-status={work.status}>{dashboardStatusDefinitions[work.status].label}</Badge>
|
||||
{work.issue && <Badge variant="outline" data-status="issue" data-issue="true">이슈</Badge>}
|
||||
</span>
|
||||
}
|
||||
|
||||
export default function WorkDashboard() {
|
||||
const [works, setWorks] = useState(loadWorks)
|
||||
const [status, setStatus] = useState<DashboardFilterId>("all")
|
||||
const [draft, setDraft] = useState<Conditions>(emptyConditions)
|
||||
const [applied, setApplied] = useState<Conditions>(emptyConditions)
|
||||
const [editor, setEditor] = useState<Work | null>(null)
|
||||
const [notice, setNotice] = useState("")
|
||||
const [section, setSection] = useState("overview")
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const goToSection = (id: string, event?: MouseEvent<HTMLAnchorElement>) => {
|
||||
event?.preventDefault()
|
||||
setSection(id)
|
||||
if (id === "overview") mainRef.current?.scrollTo({ top: 0, behavior: "instant" })
|
||||
else document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
}
|
||||
const count = (filter: DashboardFilterId) => countDashboardFilter(works, filter)
|
||||
const completion = works.length ? Math.round(count("done") / works.length * 100) : 0
|
||||
const today = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Seoul" }).format(new Date())
|
||||
const dateLabel = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", month: "long", day: "numeric", weekday: "long" }).format(new Date())
|
||||
|
||||
const visibleWorks = useMemo(() => {
|
||||
const query = applied.query.trim().toLocaleLowerCase()
|
||||
return works.filter(work => matchesDashboardFilter(work, status)
|
||||
&& (applied.department === "all" || work.department === applied.department)
|
||||
&& [work.name, work.owner, work.project, work.id].join(" ").toLocaleLowerCase().includes(query))
|
||||
}, [works, status, applied])
|
||||
|
||||
const columns = useMemo<DataTableColumnDef<Work>[]>(() => [
|
||||
dataTableSelectColumn<Work>(),
|
||||
{ accessorKey: "name", sortFn: "alphanumeric", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" />,
|
||||
cell: ({ row }) => <div className="work-name-cell">
|
||||
<Button variant="link" size="sm" className="work-name-link" onClick={() => setEditor({ ...row.original })}>{row.original.name}</Button>
|
||||
<span className="work-meta">{row.original.id} · {row.original.project}</span>
|
||||
</div> },
|
||||
{ accessorKey: "status", header: "상태", enableSorting: false, cell: ({ row }) => statusBadges(row.original) },
|
||||
{ accessorKey: "owner", header: "담당자", cell: ({ row }) => <div className="work-owner"><Avatar className="size-7"><AvatarFallback>{row.original.owner.slice(0, 1)}</AvatarFallback></Avatar><span>{row.original.owner}</span></div> },
|
||||
{ accessorKey: "due", sortFn: "basic", header: ({ column }) => <DataTableColumnHeader column={column} title="마감일" />,
|
||||
cell: ({ row }) => <span className={row.original.status !== "done" && row.original.due < today ? "work-overdue" : ""}>{row.original.due.slice(5).replace("-", ".")} {row.original.status !== "done" && row.original.due < today ? "지연" : ""}</span> },
|
||||
{ accessorKey: "progress", header: "진행률", cell: ({ row }) => <div className="work-row-progress"><span>{row.original.progress}%</span><Progress value={row.original.progress} aria-label={`${row.original.name} 진행률`} /></div> },
|
||||
], [today])
|
||||
const table = useDataTable({ columns, data: visibleWorks, getRowId: work => work.id, initialState: { pagination: { pageIndex: 0, pageSize: 6 } } })
|
||||
|
||||
const changeStatus = (filter: DashboardFilterId) => { setStatus(filter); table.setPageIndex(0); table.resetRowSelection() }
|
||||
const apply = () => { setApplied({ ...draft }); table.setPageIndex(0); table.resetRowSelection() }
|
||||
const reset = () => { setDraft(emptyConditions); setApplied(emptyConditions); changeStatus("all") }
|
||||
const openNew = () => setEditor({ id: "", name: "", project: projects[0], department: departments[0], owner: "", due: today, status: "active", issue: false, progress: 0, description: "" })
|
||||
const save = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!editor || !editor.name.trim() || !editor.owner.trim()) return
|
||||
const saved: Work = { ...editor, id: editor.id || `HM-${crypto.randomUUID().slice(0, 8)}`, name: editor.name.trim(), owner: editor.owner.trim(), progress: editor.status === "done" ? 100 : editor.progress }
|
||||
const updated = editor.id ? works.map(work => work.id === saved.id ? saved : work) : [saved, ...works]
|
||||
setWorks(updated)
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(updated)); setNotice(`“${saved.name}” ${editor.id ? "변경 사항을 저장했습니다." : "업무를 등록했습니다."}`) }
|
||||
catch { setNotice("변경 내용을 화면에 반영했습니다. 브라우저 저장이 제한되어 새로고침하면 유지되지 않습니다.") }
|
||||
setEditor(null)
|
||||
reset()
|
||||
}
|
||||
const urgent = useMemo(() => works.filter(work => work.status !== "done").sort((a, b) => a.due.localeCompare(b.due)).slice(0, 3), [works])
|
||||
const projectStats = useMemo(() => [...new Set(works.map(work => work.project))].map(project => {
|
||||
const rows = works.filter(work => work.project === project)
|
||||
return { name: project, total: rows.length, done: countDashboardFilter(rows, "done"), progress: Math.round(rows.reduce((sum, work) => sum + work.progress, 0) / rows.length) }
|
||||
}), [works])
|
||||
const statusButton = (filter: DashboardFilterId) => <Button key={filter} variant="primary" appearance={status === filter ? "solid" : "outline"} size="sm" data-filter={filter} aria-pressed={status === filter} aria-controls="work-results" onClick={() => changeStatus(filter)}>
|
||||
{dashboardFilterDefinitions[filter].label} <strong>{count(filter)}</strong>건
|
||||
</Button>
|
||||
|
||||
return <>
|
||||
<div className="dashboard-production work-dashboard">
|
||||
<GNB>
|
||||
<GNBMobileMenu title="업무 메뉴">{sections.map(item => <GNBMobileLink key={item.id} href={`#${item.id}`} active={section === item.id} onClick={event => goToSection(item.id, event)}>{item.label}</GNBMobileLink>)}</GNBMobileMenu>
|
||||
<GNBBrand href="#overview" onClick={event => goToSection("overview", event)}><span><small>HANMAC · WORKSPACE</small><strong>HM 업무 관리</strong></span></GNBBrand>
|
||||
<GNBNav><NavigationMenu viewport={false} aria-label="주 메뉴"><NavigationMenuList>{sections.map(item => <NavigationMenuItem key={item.id}><NavigationMenuLink href={`#${item.id}`} active={section === item.id} onClick={event => goToSection(item.id, event)}>{item.label}</NavigationMenuLink></NavigationMenuItem>)}</NavigationMenuList></NavigationMenu></GNBNav>
|
||||
<GNBUtilities><DropdownMenu><DropdownMenuTrigger asChild><GNBUser name="김한맥" description="프로젝트 관리자"><Avatar><AvatarFallback>김</AvatarFallback></Avatar></GNBUser></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuLabel>김한맥 · 프로젝트 관리자</DropdownMenuLabel><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => { const conditions = { query: "김한맥", department: "all" }; setDraft(conditions); setApplied(conditions); changeStatus("all"); setSection("work-results"); document.getElementById("work-results")?.scrollIntoView({ block: "start" }) }}>내 담당 업무</DropdownMenuItem><DropdownMenuItem onSelect={openNew}>업무 등록</DropdownMenuItem></DropdownMenuContent></DropdownMenu></GNBUtilities>
|
||||
</GNB>
|
||||
<main className="work-page-main" id="overview" ref={mainRef}>
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<p className="work-kicker">HANMAC · WORKSPACE</p>
|
||||
<PageHeaderTitle>업무 현황</PageHeaderTitle>
|
||||
<PageHeaderDescription>팀의 업무와 프로젝트 진행 상황을 한눈에 확인하세요.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions><span className="work-date"><CalendarDays size={16} aria-hidden="true" />{dateLabel}</span><Button variant="primary" appearance="brand" onClick={openNew}><Plus aria-hidden="true" />업무 등록</Button></PageHeaderActions>
|
||||
</PageHeader>
|
||||
|
||||
<div className="work-content-stack">
|
||||
<div className="work-status-filters" role="group" aria-label="업무 상태 필터"><span className="text-sm font-medium">업무 상태</span>{dashboardFilterIds.map(statusButton)}</div>
|
||||
<div className="work-summary-grid">
|
||||
{[
|
||||
{ title: "전체 업무", value: count("all"), note: `${projectStats.length}개 프로젝트의 업무`, icon: ClipboardList },
|
||||
{ title: "진행 중", value: count("active"), note: "현재 진행 중인 업무", icon: Clock3 },
|
||||
{ title: "완료 업무", value: count("done"), note: `전체 업무의 ${completion}% 완료`, icon: CheckCheck },
|
||||
{ title: "확인할 이슈", value: count("issue"), note: `보류 업무 ${count("hold")}건 · 이슈는 별도 표시`, icon: AlertCircle },
|
||||
].map(item => <Card key={item.title}>
|
||||
<CardHeader><CardDescription>{item.title}</CardDescription><CardAction><item.icon className="work-summary-icon" size={20} aria-hidden="true" /></CardAction></CardHeader>
|
||||
<CardContent><p className="work-summary-value">{item.value}<span>건</span></p><p className="work-meta mt-2">{item.note}</p></CardContent>
|
||||
</Card>)}
|
||||
</div>
|
||||
|
||||
<div className="work-detail-grid">
|
||||
<Card id="work-results">
|
||||
<CardHeader><CardTitle>업무 목록</CardTitle><CardDescription role="status" aria-live="polite">{dashboardFilterDefinitions[status].label} · {applied.department === "all" ? "전체 부서" : applied.department} · {visibleWorks.length}건{applied.query.trim() && ` · “${applied.query.trim()}”`}</CardDescription><CardAction><Badge variant="outline">{visibleWorks.length}건</Badge></CardAction></CardHeader>
|
||||
<CardContent>
|
||||
<form className="mb-4" onSubmit={event => { event.preventDefault(); apply() }}>
|
||||
<FilterBar className="hm-list-search-basic">
|
||||
<Select value={draft.department} onValueChange={department => setDraft(previous => ({ ...previous, department }))}>
|
||||
<SelectTrigger size="medium" aria-label="부서 조회"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">전체 부서</SelectItem>{departments.map(department => <SelectItem key={department} value={department}>{department}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<FilterBarSearch value={draft.query} onValueChange={query => setDraft(previous => ({ ...previous, query }))} placeholder="업무명·프로젝트·담당자 검색" />
|
||||
<FilterBarActions><Button type="submit" variant="primary" appearance="solid"><Search aria-hidden="true" />조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button></FilterBarActions>
|
||||
</FilterBar>
|
||||
</form>
|
||||
<DataTable table={table} pageSizeOptions={[6, 12, 24]} emptyMessage={<div className="grid justify-items-center gap-3"><p>조회 조건에 맞는 업무가 없습니다.</p><Button variant="cancel" appearance="outline" onClick={reset}>조건 초기화</Button></div>} />
|
||||
<p className="work-meta mt-4">업무명을 선택하면 상세 내용과 상태를 수정할 수 있습니다.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="work-rail">
|
||||
<Card id="project-overview">
|
||||
<CardHeader><CardTitle>프로젝트 진행률</CardTitle><CardDescription>프로젝트별 업무 평균 진행률</CardDescription></CardHeader>
|
||||
<CardContent><div className="work-project-list">{projectStats.map(project => <div key={project.name} className="work-project">
|
||||
<div className="flex items-center justify-between gap-2"><strong className="text-sm">{project.name}</strong><span className="work-project-percent">{project.progress}%</span></div>
|
||||
<Progress value={project.progress} aria-label={`${project.name} 평균 진행률`} />
|
||||
<span className="work-meta">{project.total}건 중 {project.done}건 완료</span>
|
||||
</div>)}</div></CardContent>
|
||||
</Card>
|
||||
<Card surface="content">
|
||||
<CardHeader><CardTitle>확인이 필요한 업무</CardTitle><CardDescription>진행·보류 상태 중 이슈가 있는 업무</CardDescription></CardHeader>
|
||||
<CardContent><div className="work-issue-list">{works.filter(work => work.issue && work.status !== "done").map(work => <div key={work.id} className="work-issue-item"><div className="flex items-center justify-between gap-2"><span className="work-meta">{work.project}</span>{statusBadges(work)}</div><Button className="work-name-link" variant="link" size="sm" onClick={() => setEditor({ ...work })}>{work.name}<ArrowUpRight size={14} aria-hidden="true" /></Button></div>)}
|
||||
{!works.some(work => work.issue && work.status !== "done") && <p className="work-meta">현재 확인이 필요한 이슈가 없습니다.</p>}
|
||||
</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card id="work-deadlines">
|
||||
<CardHeader><CardTitle>다가오는 마감</CardTitle><CardDescription>완료되지 않은 업무를 마감일 순으로 표시합니다.</CardDescription></CardHeader>
|
||||
<CardContent><div className="work-deadline-grid">{urgent.map(work => <div key={work.id} className="work-deadline-item">
|
||||
<div className="work-deadline-date"><CalendarDays size={18} aria-hidden="true" /><span>{work.due.slice(5).replace("-", ".")}</span></div>
|
||||
<div><Button variant="link" className="work-name-link" size="sm" onClick={() => setEditor({ ...work })}>{work.name}</Button><p className="work-meta">{work.owner} · {work.department}</p></div>
|
||||
{statusBadges(work)}
|
||||
</div>)}{!urgent.length && <p className="work-meta">모든 업무가 완료되었습니다.</p>}</div></CardContent>
|
||||
</Card>
|
||||
<footer className="work-footer"><span>HMDesign · 예시 업무 데이터 / 이 브라우저에 저장</span><span role="status" aria-live="polite">{notice}</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={editor !== null} onOpenChange={open => { if (!open) setEditor(null) }}>
|
||||
<DialogContent className="work-editor">
|
||||
<DialogHeader><DialogTitle>{editor?.id ? "업무 상세" : "업무 등록"}</DialogTitle><DialogDescription>{editor?.id ? `${editor.id} · 업무 내용과 진행 상태를 수정할 수 있습니다.` : "새로운 업무의 담당자와 마감일을 설정하세요."}</DialogDescription></DialogHeader>
|
||||
{editor && <form onSubmit={save} className="work-editor-form">
|
||||
<DialogBody>
|
||||
<div className="work-editor-fields">
|
||||
<div className="work-field work-field-wide"><Label htmlFor="work-name">업무명 <span aria-hidden="true">*</span></Label><Input id="work-name" size="medium" required maxLength={80} value={editor.name} onChange={event => setEditor({ ...editor, name: event.target.value })} placeholder="업무명을 입력하세요" /></div>
|
||||
<div className="work-field"><Label htmlFor="work-project">프로젝트</Label><Select value={editor.project} onValueChange={project => setEditor({ ...editor, project })}><SelectTrigger id="work-project" size="medium"><SelectValue /></SelectTrigger><SelectContent>{projects.map(project => <SelectItem key={project} value={project}>{project}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="work-field"><Label htmlFor="work-department">부서</Label><Select value={editor.department} onValueChange={department => setEditor({ ...editor, department })}><SelectTrigger id="work-department" size="medium"><SelectValue /></SelectTrigger><SelectContent>{departments.map(department => <SelectItem key={department} value={department}>{department}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="work-field"><Label htmlFor="work-owner">담당자 <span aria-hidden="true">*</span></Label><Input id="work-owner" size="medium" required maxLength={30} value={editor.owner} onChange={event => setEditor({ ...editor, owner: event.target.value })} placeholder="담당자 이름" /></div>
|
||||
<div className="work-field"><Label htmlFor="work-due">마감일 <span aria-hidden="true">*</span></Label><DatePicker id="work-due" aria-label="마감일" controlled size="medium" value={new Date(`${editor.due}T12:00:00`)} onValueChange={date => { if (date) setEditor({ ...editor, due: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` }) }} /></div>
|
||||
<div className="work-field"><Label htmlFor="work-status">업무 상태</Label><Select value={editor.status} onValueChange={value => setEditor({ ...editor, status: value as DashboardStatusId, progress: value === "done" ? 100 : editor.status === "done" ? 0 : editor.progress })}><SelectTrigger id="work-status" size="medium"><SelectValue /></SelectTrigger><SelectContent>{Object.values(dashboardStatusDefinitions).map(definition => <SelectItem key={definition.id} value={definition.id}>{definition.label}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div className="work-field"><Label htmlFor="work-progress">진행률 (%)</Label><Input id="work-progress" type="number" size="medium" min={0} max={100} required disabled={editor.status === "done"} value={editor.progress} onChange={event => setEditor({ ...editor, progress: Number(event.target.value) })} /></div>
|
||||
<div className="work-field work-field-wide"><Label htmlFor="work-description">업무 설명</Label><Textarea id="work-description" maxLength={1000} rows={3} value={editor.description} onChange={event => setEditor({ ...editor, description: event.target.value })} placeholder="업무 내용과 참고 사항을 입력하세요" /></div>
|
||||
<div className="work-field-wide flex items-center justify-between gap-4"><div><Label htmlFor="work-issue">이슈 표시</Label><p className="work-meta mt-1">업무 상태와 별도로 확인이 필요한 사항을 표시합니다.</p></div><Switch id="work-issue" checked={editor.issue} onCheckedChange={issue => setEditor({ ...editor, issue })} /></div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter><Button type="button" variant="cancel" appearance="solid" onClick={() => setEditor(null)}>취소</Button><Button type="submit" variant="primary" appearance="solid">{editor.id ? "변경 저장" : "등록"}</Button></DialogFooter>
|
||||
</form>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DashboardStatusId } from "@hm-design/ui"
|
||||
|
||||
export type Work = {
|
||||
id: string
|
||||
name: string
|
||||
project: string
|
||||
department: string
|
||||
owner: string
|
||||
due: string
|
||||
status: DashboardStatusId
|
||||
issue: boolean
|
||||
progress: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export const departments = ["설계팀", "개발팀", "운영팀"]
|
||||
export const projects = ["통합 업무 플랫폼", "고객 포털 고도화", "디자인 시스템", "운영 프로세스 개선"]
|
||||
export const storageKey = "hmdesign.work-dashboard.v1"
|
||||
|
||||
export const sampleWorks: Work[] = [
|
||||
{ id: "HM-001", name: "업무 현황 화면 설계", project: projects[0], department: "설계팀", owner: "김한맥", due: "2026-10-12", status: "active", issue: false, progress: 75, description: "업무 흐름과 사용자 요구사항을 반영하여 현황 화면을 설계합니다." },
|
||||
{ id: "HM-002", name: "권한 관리 API 연동", project: projects[0], department: "개발팀", owner: "이서준", due: "2026-10-12", status: "active", issue: true, progress: 45, description: "권한 정책 확정이 필요합니다. 운영팀과 정책을 확인한 뒤 API 연동을 진행합니다." },
|
||||
{ id: "HM-003", name: "고객 포털 접근성 검토", project: projects[1], department: "설계팀", owner: "박지민", due: "2026-10-13", status: "active", issue: false, progress: 60, description: "주요 사용자 흐름의 키보드 조작과 접근성 요소를 검토합니다." },
|
||||
{ id: "HM-004", name: "공통 컴포넌트 가이드 정리", project: projects[2], department: "설계팀", owner: "김한맥", due: "2026-10-14", status: "active", issue: false, progress: 80, description: "공개 컴포넌트 사용법과 화면 조합 예제를 정리합니다." },
|
||||
{ id: "HM-005", name: "운영 이관 체크리스트 검토", project: projects[3], department: "운영팀", owner: "정수빈", due: "2026-10-14", status: "hold", issue: true, progress: 30, description: "담당 부서의 이관 범위 확인을 기다리고 있습니다." },
|
||||
{ id: "HM-006", name: "검색 기능 통합 테스트", project: projects[1], department: "개발팀", owner: "최도윤", due: "2026-10-15", status: "active", issue: false, progress: 55, description: "검색 조건과 결과 목록의 동작을 통합 검증합니다." },
|
||||
{ id: "HM-007", name: "알림 발송 정책 수립", project: projects[0], department: "운영팀", owner: "정수빈", due: "2026-10-16", status: "hold", issue: false, progress: 20, description: "업무 변경 알림의 대상과 발송 시점을 정의합니다." },
|
||||
{ id: "HM-008", name: "Dashboard 상태 규칙 검토", project: projects[2], department: "설계팀", owner: "박지민", due: "2026-10-08", status: "done", issue: false, progress: 100, description: "업무 상태와 독립 이슈 표시 규칙을 검토했습니다." },
|
||||
{ id: "HM-009", name: "배포 환경 설정", project: projects[0], department: "개발팀", owner: "이서준", due: "2026-10-08", status: "done", issue: false, progress: 100, description: "개발 환경의 배포 설정과 빌드 작업을 구성했습니다." },
|
||||
{ id: "HM-010", name: "고객 문의 분류 체계 정리", project: projects[3], department: "운영팀", owner: "정수빈", due: "2026-10-07", status: "done", issue: false, progress: 100, description: "문의 유형별 담당 부서와 처리 기준을 정리했습니다." },
|
||||
{ id: "HM-011", name: "포털 화면 사용성 테스트", project: projects[1], department: "설계팀", owner: "김한맥", due: "2026-10-19", status: "active", issue: false, progress: 35, description: "고객 포털의 주요 화면 사용성 테스트를 준비합니다." },
|
||||
{ id: "HM-012", name: "업무 등록 기능 개발", project: projects[0], department: "개발팀", owner: "최도윤", due: "2026-10-20", status: "active", issue: false, progress: 65, description: "업무 등록 폼과 입력 검증을 구현합니다." },
|
||||
]
|
||||
|
||||
function isWork(value: unknown): value is Work {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const work = value as Record<string, unknown>
|
||||
return ["id", "name", "project", "department", "owner", "description"].every(key => typeof work[key] === "string")
|
||||
&& typeof work.due === "string" && /^\d{4}-\d{2}-\d{2}$/.test(work.due)
|
||||
&& ["active", "hold", "done"].includes(String(work.status)) && typeof work.issue === "boolean"
|
||||
&& typeof work.progress === "number" && work.progress >= 0 && work.progress <= 100
|
||||
}
|
||||
|
||||
export function loadWorks(): Work[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (Array.isArray(parsed) && parsed.every(isWork) && new Set(parsed.map(work => work.id)).size === parsed.length) return parsed
|
||||
}
|
||||
} catch { /* Storage may be disabled; the screen still works in memory. */ }
|
||||
return sampleWorks
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@import "../list-screen.css";
|
||||
/* Screen composition only. HM primitives, typography and surfaces retain their public Theme mappings. */
|
||||
.work-dashboard { display: flex; flex-direction: column; height: 100dvh; min-width: 0; color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-brand); font-family: var(--hm-font-family-base); }
|
||||
.work-page-main { position: relative; flex: 1; min-height: 0; overflow: auto; padding: var(--hm-gnb-padding-x); background: var(--hm-sem-bg-content); border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0; }
|
||||
.work-content-stack { display: grid; gap: var(--hm-card-padding); min-width: 0; }
|
||||
.work-status-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.work-status-filters > span { margin-right: 8px; }
|
||||
#work-results, #project-overview, #work-deadlines { scroll-margin-top: var(--hm-card-padding); }
|
||||
.work-kicker { margin: 0; color: var(--hm-sem-fg-secondary); font-size: 12px; font-weight: 600; letter-spacing: .12em; }
|
||||
.work-date { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--hm-sem-fg-secondary); font-size: 13px; white-space: nowrap; }
|
||||
.work-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--hm-card-padding); }
|
||||
.work-summary-icon { color: var(--hm-sem-action-primary); }
|
||||
.work-summary-value { margin: 0; font-size: 36px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.work-summary-value > span { margin-left: 8px; font-size: 14px; font-weight: 400; color: var(--hm-sem-fg-secondary); }
|
||||
.work-meta { color: var(--hm-sem-fg-secondary); font-size: 12px; line-height: 1.6; }
|
||||
.work-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; gap: var(--hm-card-padding); }
|
||||
.work-rail { display: grid; gap: var(--hm-card-padding); }
|
||||
.work-badges { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.work-name-cell { display: grid; gap: 4px; min-width: 210px; }
|
||||
.work-name-link { justify-content: flex-start; height: auto; padding: 0; text-align: left; white-space: normal; }
|
||||
.work-owner { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.work-row-progress { display: grid; gap: 6px; min-width: 72px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.work-overdue { color: var(--hm-sem-action-destructive); }
|
||||
.work-project-list { display: grid; gap: 24px; }
|
||||
.work-project { display: grid; gap: 8px; }
|
||||
.work-project-percent { color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.work-issue-list { display: grid; gap: 20px; }
|
||||
.work-issue-item { display: grid; gap: 8px; }
|
||||
.work-deadline-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
|
||||
.work-deadline-item { display: flex; align-items: center; gap: 16px; }
|
||||
.work-deadline-date { display: grid; justify-items: center; gap: 4px; flex: none; color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 600; }
|
||||
.work-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-bottom: 8px; color: var(--hm-sem-fg-secondary); font-size: 12px; }
|
||||
.work-editor { width: min(560px, calc(100vw - 32px)); }
|
||||
.work-editor-form { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.work-editor-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.work-field { display: grid; gap: 8px; }
|
||||
.work-field-wide { grid-column: 1 / -1; }
|
||||
@media (max-width: 1199px) {
|
||||
.work-detail-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.work-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.work-deadline-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.work-summary-grid, .work-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.work-editor-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.work-date { display: none; }
|
||||
}
|
||||
@media (min-width: 521px) and (max-width: 959px) {
|
||||
.work-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
Reference in new issue
Block a user