feat: integrate latest HMDesign design system updates

This commit is contained in:
DESKTOP-KPCP6AL\ㅅㅎ committed 2026-10-11 11:09:03 +09:00
1 parent 99faec189e
commit 1c4e0bb9a1
209 files changed
+8845 -552

No files matched your search

+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>사용자 목록</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { SearchList } from "../shared/SearchList"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="사용자 목록"><SearchList title="사용자 목록"/></GNBFrame>)
+22
View File
@@ -0,0 +1,22 @@
{
"id": "01",
"title": "사용자 목록",
"difficulty": "Easy",
"mode": "GNB_ONLY",
"actions": [
"조회",
"초기화"
],
"patterns": [
"FilterBar",
"DataTable"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>현장 업무</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { SearchList } from "../shared/SearchList"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="현장 업무"><SearchList title="현장 업무" cards/></GNBFrame>)
+21
View File
@@ -0,0 +1,21 @@
{
"id": "02",
"title": "현장 업무",
"difficulty": "Medium",
"mode": "GNB_ONLY",
"actions": [
"조회",
"초기화"
],
"patterns": [
"FilterBar"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>승인 결재</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { SearchList } from "../shared/SearchList"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="승인 결재"><SearchList title="승인 결재" detail="dialog"/></GNBFrame>)
+25
View File
@@ -0,0 +1,25 @@
{
"id": "03",
"title": "승인 결재",
"difficulty": "Hard",
"mode": "GNB_ONLY",
"actions": [
"조회",
"초기화",
"결재 확인",
"취소",
"승인"
],
"patterns": [
"FilterBar",
"DataTable"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>채용 관리</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { SearchList } from "../shared/SearchList"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="채용 관리"><SearchList title="채용 관리" detail="sheet"/></GNBFrame>)
+24
View File
@@ -0,0 +1,24 @@
{
"id": "04",
"title": "채용 관리",
"difficulty": "Hard",
"mode": "GNB_ONLY",
"actions": [
"조회",
"초기화",
"채용 상세",
"확인"
],
"patterns": [
"FilterBar",
"DataTable"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>업무 요청</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { RequestForm } from "../shared/RequestForm"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="업무 요청"><RequestForm/></GNBFrame>)
+18
View File
@@ -0,0 +1,18 @@
{
"id": "05",
"title": "업무 요청",
"difficulty": "Medium",
"mode": "GNB_ONLY",
"actions": [
"제출"
],
"patterns": [],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>권한 설정</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { Permissions } from "../shared/Permissions"
import { LNBFrame } from "../shared/LNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<LNBFrame title="권한 설정"><Permissions/></LNBFrame>)
+18
View File
@@ -0,0 +1,18 @@
{
"id": "06",
"title": "권한 설정",
"difficulty": "Medium",
"mode": "LNB_ONLY",
"actions": [
"저장"
],
"patterns": [],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>업무 Dashboard</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client"
import { Dashboard } from "../shared/Dashboard"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<Dashboard/>)
+18
View File
@@ -0,0 +1,18 @@
{
"id": "07",
"title": "업무 Dashboard",
"difficulty": "Medium",
"mode": "DASHBOARD_ONLY",
"actions": [],
"patterns": [
"DashboardLayout"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>통계 Dashboard</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client"
import { Dashboard } from "../shared/Dashboard"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<Dashboard statistics/>)
+19
View File
@@ -0,0 +1,19 @@
{
"id": "08",
"title": "통계 Dashboard",
"difficulty": "Medium",
"mode": "DASHBOARD_ONLY",
"actions": [],
"patterns": [
"DashboardLayout",
"DataTable"
],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>작업 변경</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { OverlayWorkflow } from "../shared/OverlayWorkflow"
import { GNBFrame } from "../shared/GNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<GNBFrame title="작업 변경"><OverlayWorkflow/></GNBFrame>)
+20
View File
@@ -0,0 +1,20 @@
{
"id": "09",
"title": "작업 변경",
"difficulty": "Hard",
"mode": "GNB_ONLY",
"actions": [
"작업 변경 열기",
"취소",
"적용"
],
"patterns": [],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="ko"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><link rel="icon" href="data:,"/><title>처리 상태</title></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client"
import { StateWorkflow } from "../shared/StateWorkflow"
import { LNBFrame } from "../shared/LNBFrame"
import "../shared/index.css"
import "../shared/hm-theme.css"
createRoot(document.getElementById("root")!).render(<LNBFrame title="처리 상태"><StateWorkflow/></LNBFrame>)
+21
View File
@@ -0,0 +1,21 @@
{
"id": "10",
"title": "처리 상태",
"difficulty": "Easy",
"mode": "LNB_ONLY",
"actions": [
"로딩",
"오류",
"빈 결과",
"다시 시도"
],
"patterns": [],
"generation": {
"model": "GPT-6 (session identity)",
"reasoningLevel": "UNAVAILABLE",
"randomSeed": null,
"seedReason": "Session sampling seed unavailable",
"condition": "Generated in current Codex session, shared compositions, no previous experiment solution source supplied",
"comparability": "NON-COMPARABLE"
}
}
+32
View File
@@ -0,0 +1,32 @@
# AI 생성 품질 실험
신규 React 화면 10개. 같은 Codex 세션에서 작성한 공통 composition을 사용한다. 이전 실험 소스를 정답으로 사용하지 않았다. `requirements.json`에 난이도·Navigation·허용 액션·Pattern·모델·추론/Seed 가용성을 기록한다. 이전 실험 또는 독립 모델 비교와 NON-COMPARABLE이다.
```sh
node scripts/prepare-quality-experiments.mjs
npm run build:packages
node node_modules/typescript/bin/tsc -p examples/ai-quality/tsconfig.json
node node_modules/vite/bin/vite.js build --config examples/ai-quality/vite.config.ts
node node_modules/vite/bin/vite.js preview --config examples/ai-quality/vite.config.ts --host 127.0.0.1 --port 6321
```
`http://127.0.0.1:6321/01/index.html`부터 `10/index.html`까지 각각 독립 React bootstrap이다. 신규 Story·추가 운영 메뉴는 없다. 생성 스크립트는 entry·요구사항만 재생성하며 공통 화면 소스는 덮어쓰지 않는다.
브라우저 검사는 설치된 Playwright 모듈과 Chrome 실행 경로를 `HM_PLAYWRIGHT_DIR`, `HM_CHROME_PATH` 환경 변수로 제공한 뒤 `node scripts/verify-quality-runtime.mjs`를 실행한다. 결과·원본 Guard·40개 캡처는 `_local/quality-20261010`에 기록된다. 실패한 화면만 재검사할 때는 `node scripts/verify-quality-runtime.mjs 03 04`처럼 ID를 전달한다. 미검증 환경에는 임의 PASS를 기록하지 않는다.
| ID | 화면 | 난이도 | Navigation | 선택 이유 |
|---|---|---|---|---|
| 01 | 사용자 목록 | Easy | GNB | 서비스 헤더 + FilterBar/DataTable |
| 02 | 현장 업무 | Medium | GNB | FilterBar + 카드 결과, Table 불필요 |
| 03 | 승인 결재 | Hard | GNB | FilterBar/DataTable + Dialog 확인 |
| 04 | 채용 관리 | Hard | GNB | FilterBar/DataTable + Sheet 상세 |
| 05 | 업무 요청 | Medium | GNB | 별도 Form Pattern 없음, Label/Input/Checkbox composition |
| 06 | 권한 설정 | Medium | LNB | 설정 영역 단일 메뉴, Checkbox/Switch composition |
| 07 | 업무 Dashboard | Medium | Dashboard | 전용 48px Header·960px frame·기간별 지표 |
| 08 | 통계 Dashboard | Medium | Dashboard | Tabs/Progress 지표 + DataTable 분류별 집계, 공개 Chart API 없음 |
| 09 | 작업 변경 | Hard | GNB | Drawer 확인·적용 |
| 10 | 처리 상태 | Easy | LNB | Skeleton/Alert/빈 결과 composition, 검색 요구 없음 |
Navigation은 실험 요구사항에서 명시한 모드이며 하나의 화면 제목만 메뉴로 제공한다. 이 선택을 일반 업무 화면의 기본 모드로 일반화하지 않는다. DataTable의 페이지 크기·페이지 이동, 검색 Clear, Overlay Close는 선택한 공개 API의 기본 기능이며 새로운 업무 메뉴/CSV가 아니다. Dashboard Status는 해당 도메인 상태 필터 요청이 없으므로 강제하지 않는다. 공통 HM 토큰·기존 CSS entry를 소비하며 임의 token은 없다.
스크린리더 발화, 실기기 touch/drag, 실제 backend, 교차 모델과 모든 상태 조합은 NOT VERIFIED다. 점수 rubric을 실행하지 않았으므로 디자인 점수는 NOT VERIFIED이며 PASS 비율과 혼용하지 않는다.
+14
View File
@@ -0,0 +1,14 @@
import { useMemo, useState } from "react"
import { DashboardLayout, DashboardHeader, DashboardMain, DashboardContent, Card, CardHeader, CardTitle, CardContent, Progress, Tabs, TabsList, TabsTrigger, TabsContent, DataTable, useDataTable, type DataTableColumnDef } from "@hm-design/ui"
import { Heading } from "./Heading"
type Summary = { category: string; total: number; completed: number }
const columns: DataTableColumnDef<Summary>[] = [{ accessorKey: "category", header: "업무 분류" }, { accessorKey: "total", header: "전체 건수" }, { accessorKey: "completed", header: "완료 건수" }]
export function Dashboard({ statistics = false }: { statistics?: boolean }) {
const [period, setPeriod] = useState("week")
const data = useMemo(() => ["설계", "시공", "안전"].map((category, i) => ({ category, total: (period === "week" ? 12 : 48) + i, completed: (period === "week" ? 8 : 32) + i })), [period])
const table = useDataTable({ columns, data })
const title = statistics ? "통계 Dashboard" : "업무 Dashboard"
return <DashboardLayout header={<DashboardHeader><strong>{title}</strong></DashboardHeader>}><DashboardMain><DashboardContent><Heading title={title} description="기간별 건수와 진행률을 확인합니다."/><Tabs value={period} onValueChange={setPeriod}><TabsList aria-label="조회 기간"><TabsTrigger value="week">이번 주</TabsTrigger><TabsTrigger value="month">이번 달</TabsTrigger></TabsList>{["week", "month"].map(p => <TabsContent key={p} value={p}><div className="quality-metrics">{["진행", "대기", "완료"].map((label, i) => <Card key={label}><CardHeader><CardTitle>{label}</CardTitle></CardHeader><CardContent><p>{(p === "week" ? 12 : 48) + i}건</p><Progress value={40 + i * 20} aria-label={`${label} 진행률`}/></CardContent></Card>)}</div></TabsContent>)}</Tabs>{statistics && <Card><CardHeader><CardTitle>업무 분류별 집계</CardTitle></CardHeader><CardContent><DataTable table={table}/></CardContent></Card>}</DashboardContent></DashboardMain></DashboardLayout>
}
+6
View File
@@ -0,0 +1,6 @@
import { type ReactNode } from "react"
import { GNB, GNBBrand, GNBMobileMenu, GNBMobileLink } from "@hm-design/ui"
export function GNBFrame({ title, children }: { title: string; children: ReactNode }) {
return <><GNB><GNBMobileMenu title={`${title} 메뉴`}><GNBMobileLink href="#content" active>{title}</GNBMobileLink></GNBMobileMenu><GNBBrand href="#content"><span><small>HMDesign</small><strong>{title}</strong></span></GNBBrand></GNB><main id="content" className="quality-main">{children}</main></>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageHeader, PageHeaderContent, PageHeaderTitle, PageHeaderDescription } from "@hm-design/ui"
export function Heading({ title, description }: { title: string; description: string }) {
return <PageHeader><PageHeaderContent><PageHeaderTitle>{title}</PageHeaderTitle><PageHeaderDescription>{description}</PageHeaderDescription></PageHeaderContent></PageHeader>
}
+6
View File
@@ -0,0 +1,6 @@
import { type ReactNode } from "react"
import { LNB, LNBBrand, LNBNav, LNBItem, LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink } from "@hm-design/ui"
export function LNBFrame({ title, children }: { title: string; children: ReactNode }) {
return <div className="quality-lnb"><LNB><LNBBrand><span><small>HMDesign</small><strong>{title}</strong></span></LNBBrand><LNBNav aria-label={`${title} 메뉴`}><LNBItem href="#content" active>{title}</LNBItem></LNBNav></LNB><div className="quality-lnb-content"><div className="quality-mobile-header"><LNBMobileHeader><LNBMobileMenu title={`${title} 메뉴`}><LNBMobileLink href="#content" active>{title}</LNBMobileLink></LNBMobileMenu><LNBMobileBrand href="#content">{title}</LNBMobileBrand></LNBMobileHeader></div><main id="content" className="quality-main">{children}</main></div></div>
}
@@ -0,0 +1,8 @@
import { useState } from "react"
import { Card, CardContent, Button, Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose } from "@hm-design/ui"
import { Heading } from "./Heading"
export function OverlayWorkflow() {
const [status, setStatus] = useState("변경 전")
return <><Heading title="작업 변경" description="Drawer에서 작업 변경을 확인하고 적용합니다."/><Card><CardContent><p role="status">{status}</p><Drawer><DrawerTrigger asChild><Button>작업 변경 열기</Button></DrawerTrigger><DrawerContent><DrawerHeader><DrawerTitle>작업 변경</DrawerTitle><DrawerDescription>확인 후 적용하면 변경 상태를 갱신합니다.</DrawerDescription></DrawerHeader><DrawerFooter><DrawerClose asChild><Button variant="cancel" appearance="solid">취소</Button></DrawerClose><DrawerClose asChild><Button onClick={() => setStatus("변경 적용 완료")}>적용</Button></DrawerClose></DrawerFooter></DrawerContent></Drawer></CardContent></Card></>
}
@@ -0,0 +1,14 @@
import { useId, useState } from "react"
import { Card, CardContent, Button, Label, Input, Checkbox, Switch } from "@hm-design/ui"
import { Heading } from "./Heading"
export function Permissions() {
const id = useId()
const [edit, setEdit] = useState(false)
const [notice, setNotice] = useState(true)
const [status, setStatus] = useState("")
return <><Heading title="권한 설정" description="편집 권한과 알림 설정을 저장합니다."/><Card><CardContent><div className="quality-fields"><Label htmlFor={`${id}-account`}>계정</Label><Input id={`${id}-account`} readOnly value="hm-design"/>
<div className="flex items-center gap-2"><Checkbox id={`${id}-edit`} checked={edit} onCheckedChange={v => setEdit(v === true)}/><Label htmlFor={`${id}-edit`}>편집 권한</Label></div>
<div className="flex items-center gap-2"><Switch id={`${id}-notice`} checked={notice} onCheckedChange={setNotice}/><Label htmlFor={`${id}-notice`}>알림 수신</Label></div><Button onClick={() => setStatus(`저장 완료 · 편집 ${edit ? "허용" : "불가"} · 알림 ${notice ? "켜짐" : "꺼짐"}`)}>저장</Button><p role="status">{status}</p>
</div></CardContent></Card></>
}
@@ -0,0 +1,18 @@
import { useId, useRef, useState } from "react"
import { Card, CardContent, Button, Label, Input, Textarea, Checkbox } from "@hm-design/ui"
import { Heading } from "./Heading"
export function RequestForm() {
const id = useId()
const ref = useRef<HTMLInputElement>(null)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [agreed, setAgreed] = useState(false)
const [status, setStatus] = useState("")
return <><Heading title="업무 요청" description="필수 이름과 동의를 확인한 뒤 제출합니다."/><Card><CardContent><form className="quality-fields" noValidate onSubmit={e => { e.preventDefault(); if (!name.trim()) { setStatus("이름을 입력하세요."); ref.current?.focus(); return } if (!agreed) { setStatus("동의가 필요합니다."); document.getElementById(`${id}-agree`)?.focus(); return } setStatus("요청이 제출되었습니다.") }}>
<Label htmlFor={`${id}-name`}>요청자 이름</Label><Input id={`${id}-name`} ref={ref} required aria-invalid={status === "이름을 입력하세요."} aria-describedby={`${id}-status`} value={name} onChange={e => setName(e.target.value)}/>
<Label htmlFor={`${id}-description`}>요청 내용</Label><Textarea id={`${id}-description`} value={description} onChange={e => setDescription(e.target.value)}/>
<div className="flex items-center gap-2"><Checkbox id={`${id}-agree`} checked={agreed} onCheckedChange={v => setAgreed(v === true)}/><Label htmlFor={`${id}-agree`}>처리에 동의합니다</Label></div>
<p id={`${id}-status`} role="status">{status}</p><Button type="submit">제출</Button>
</form></CardContent></Card></>
}
+25
View File
@@ -0,0 +1,25 @@
import { useId, useMemo, useState } from "react"
import { Card, CardHeader, CardTitle, CardContent, Button, Label, FilterBar, FilterBarSearch, FilterBarActions, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DataTable, useDataTable, type DataTableColumnDef, Badge, Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, SheetClose } from "@hm-design/ui"
import { Heading } from "./Heading"
type Row = { id: string; name: string; group: string; status: string }
const records: Row[] = [{ id: "1", name: "김한맥", group: "설계", status: "진행" }, { id: "2", name: "이한맥", group: "시공", status: "대기" }, { id: "3", name: "박한맥", group: "설계", status: "완료" }]
const columns: DataTableColumnDef<Row>[] = [{ accessorKey: "name", header: "이름" }, { accessorKey: "group", header: "부서" }, { accessorKey: "status", header: "상태" }]
export function SearchList({ title, cards = false, detail = "none" }: { title: string; cards?: boolean; detail?: "none" | "dialog" | "sheet" }) {
const [draft, setDraft] = useState("")
const [group, setGroup] = useState("all")
const [applied, setApplied] = useState({ query: "", group: "all" })
const [confirmed, setConfirmed] = useState(false)
const rows = useMemo(() => records.filter(r => r.name.includes(applied.query) && (applied.group === "all" || r.group === applied.group)), [applied])
const table = useDataTable({ columns, data: rows, getRowId: row => row.id })
const id = useId()
return <><Heading title={title} description="부서와 이름 조건은 조회할 때 적용됩니다."/><Card><CardContent><div className="grid min-w-0 gap-4"><form onSubmit={e => { e.preventDefault(); setApplied({ query: draft, group }); table.setPageIndex(0) }}><FilterBar layout="list-basic">
<Select value={group} onValueChange={setGroup}><SelectTrigger size="medium" aria-label="부서"><SelectValue/></SelectTrigger><SelectContent><SelectItem value="all">전체 부서</SelectItem><SelectItem value="설계">설계</SelectItem><SelectItem value="시공">시공</SelectItem></SelectContent></Select>
<Label htmlFor={id} className="sr-only">이름 검색</Label><FilterBarSearch id={id} value={draft} onValueChange={setDraft} placeholder="이름 검색"/>
<FilterBarActions><Button type="submit" variant="primary" appearance="solid">조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={() => { setDraft(""); setGroup("all"); setApplied({ query: "", group: "all" }); table.setPageIndex(0) }}>초기화</Button></FilterBarActions>
</FilterBar></form><p role="status">조회 결과 {rows.length}건{confirmed ? " · 처리 완료" : ""}</p>
{cards ? <div className="grid gap-3">{rows.map(row => <Card key={row.id}><CardHeader><CardTitle>{row.name}</CardTitle></CardHeader><CardContent>{row.group} · <Badge variant="outline">{row.status}</Badge></CardContent></Card>)}{!rows.length && <p>조건에 맞는 업무가 없습니다.</p>}</div> : <DataTable table={table} emptyMessage="조건에 맞는 항목이 없습니다."/>}
{detail === "dialog" && <Dialog><DialogTrigger asChild><Button>결재 확인</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>결재 확인</DialogTitle><DialogDescription>현재 조회한 결재를 승인합니다.</DialogDescription></DialogHeader><DialogFooter><DialogClose asChild><Button variant="cancel" appearance="solid">취소</Button></DialogClose><DialogClose asChild><Button onClick={() => setConfirmed(true)}>승인</Button></DialogClose></DialogFooter></DialogContent></Dialog>}
{detail === "sheet" && <Sheet><SheetTrigger asChild><Button>채용 상세</Button></SheetTrigger><SheetContent><SheetHeader><SheetTitle>채용 상세</SheetTitle><SheetDescription>공고의 상세 내용과 확인 상태입니다.</SheetDescription></SheetHeader><SheetBody>설계 부문 채용 · 서류 검토</SheetBody><SheetFooter><SheetClose asChild><Button onClick={() => setConfirmed(true)}>확인</Button></SheetClose></SheetFooter></SheetContent></Sheet>}
</div></CardContent></Card></>
}
@@ -0,0 +1,12 @@
import { useState } from "react"
import { Card, CardContent, Button, Alert, AlertTitle, AlertDescription, Skeleton } from "@hm-design/ui"
import { Heading } from "./Heading"
export function StateWorkflow() {
const [state, setState] = useState("loading")
return <><Heading title="처리 상태" description="로딩, 오류, 빈 결과를 확인하는 작업입니다."/><Card><CardContent><div className="grid gap-4"><div className="flex flex-wrap gap-2"><Button onClick={() => setState("loading")}>로딩</Button><Button onClick={() => setState("error")}>오류</Button><Button onClick={() => setState("empty")}>빈 결과</Button></div>
{state === "loading" && <div role="status" aria-label="처리 중"><Skeleton className="h-10 w-full"/></div>}
{state === "error" && <Alert variant="destructive"><AlertTitle>처리 실패</AlertTitle><AlertDescription>다시 시도하세요.</AlertDescription><Button onClick={() => setState("empty")}>다시 시도</Button></Alert>}
{state === "empty" && <p role="status">등록된 항목이 없습니다.</p>}
</div></CardContent></Card></>
}
+12
View File
@@ -0,0 +1,12 @@
@import "@hm-design/ui/theme.css";
@import "@hm-design/ui/dashboard-status.css";
html, body { margin: 0; font-family: var(--hm-font-family-base); background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); }
.quality-main { max-width: 1180px; min-width: 0; margin: auto; padding: 24px; display: grid; gap: 20px; }
.quality-fields { display: grid; gap: 8px; max-width: 480px; }
.quality-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.quality-lnb { display: flex; min-height: 100vh; background: var(--hm-sem-bg-brand); }
.quality-lnb-content { flex: 1; min-width: 0; background: var(--hm-sem-bg-content); border-radius: var(--hm-lnb-join) 0 0 var(--hm-lnb-join); }
.quality-mobile-header { display: none; }
@media (max-width: 1023px) { .quality-mobile-header { display: block; } .quality-lnb-content { border-radius: 0; } }
@media (max-width: 640px) { .quality-main { padding: 16px; } .quality-metrics { grid-template-columns: 1fr; } }
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss" source("../");
@import "@hm-design/ui/tailwind.css";
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "skipLibCheck": false, "noEmit": true, "types": ["node", "vite/client"], "noUnusedLocals": true },
"include": ["**/*.tsx", "vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import path from "node:path"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
const root = path.resolve(import.meta.dirname)
export default defineConfig({ root, plugins: [react(), tailwindcss()], publicDir: "../../public", server: { host: "127.0.0.1", port: 6320 }, build: { outDir: path.resolve(root, "../../_local/quality-20261010/screens-dist"), emptyOutDir: true, rolldownOptions: { input: Object.fromEntries(Array.from({ length: 10 }, (_, i) => { const id = String(i + 1).padStart(2, "0"); return [id, path.join(root, id, "index.html")] })) } } })
+22
View File
@@ -0,0 +1,22 @@
# 공개 HM Component로 새 React 화면 제작·실행
GNB_ONLY를 선택했다. 단일 서비스 목록은 전역 Header와 모바일 메뉴를 사용하며 Sidebar를 추가하지 않는다. 실제 `@hm-design/ui`의 GNB·PageHeader·Card·FilterBar·Select·Button·DataTable을 렌더링한다. Storybook helper는 사용하지 않는다.
Git Clone 저장소 루트에서 AGENTS.md를 먼저 읽고 Node.js 20.x의 20.19 이상 또는 22.12 이상과 npm을 준비한다. 검증 버전은 Node 20.20.1 / npm 10.8.2다. 다음 순서로 실행한다.
```sh
npm ci
npm run build:packages
npm run dev:publisher-example
```
콘솔의 실제 주소를 연다. 기본 포트는 5173이며 사용 중이면 Vite가 다른 포트를 안내한다. 기존 프로세스를 종료하지 않는다. 이름 입력은 초안이며 조회 또는 Enter로 적용하고 초기화로 복원한다. 390px에서는 표의 내부 가로 스크롤을 사용한다.
새 화면을 만들려면 이 폴더를 복사한 뒤 제목·data·columns와 필요한 HM Component 조합을 변경한다. CSS entry와 공개 package import를 유지한다. 검증은 루트에서 다음과 같이 한 번 실행하고 실제 브라우저 결과를 확인한다.
```sh
npm run build:publisher-example
node scripts/ai-generation-guard.mjs --target examples/publisher-list --scope FULL_SCREEN --mode GNB_ONLY --result index.html
```
Guard의 RENDER-002는 실제 렌더링이 정적 검사만으로 확인되지 않았다는 NOT VERIFIED 표시다. 브라우저에서 검색·초기화·모바일 메뉴·utility 적용을 별도로 검증한다. `npm ci`에는 npm registry 접근이 필요하다. 현재 Git Clone에는 사전 빌드 화면이 포함되지 않는다. 위 개발 서버에서 실제 React 화면을 확인하며 Storybook은 별도 `npm run storybook`으로 6024에서 열람한다.
+1
View File
@@ -0,0 +1 @@
@import "@hm-design/ui/theme.css";
+5
View File
@@ -0,0 +1,5 @@
@import "tailwindcss" source("./");
@import "@hm-design/ui/tailwind.css";
@import "../../src/list-screen.css";
.publisher-example { min-height: 100dvh; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); }
.publisher-example-main { display: grid; min-width: 0; gap: var(--hm-card-padding); padding: var(--hm-gnb-padding-x); }
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>HMDesign 사용자 목록 예제</title><link rel="icon" href="data:," /></head><body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
+32
View File
@@ -0,0 +1,32 @@
import { StrictMode, useState } from "react"
import { createRoot } from "react-dom/client"
import { GNB, GNBBrand, GNBMobileMenu, GNBMobileLink, PageHeader, PageHeaderContent, PageHeaderTitle, PageHeaderDescription, Card, CardContent, FilterBar, FilterBarSearch, FilterBarActions, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Button, DataTable, DataTableColumnHeader, useDataTable, type DataTableColumnDef } from "@hm-design/ui"
import "./index.css"
import "./hm-theme.css"
// GNB_ONLY: a small list page has one service header and mobile menu. No sidebar.
type User = { id: string; name: string; department: string }
const users: User[] = [{ id: "1", name: "김한맥", department: "디자인" }, { id: "2", name: "이한맥", department: "개발" }, { id: "3", name: "박한맥", department: "디자인" }]
const columns: DataTableColumnDef<User>[] = [{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="이름" /> }, { accessorKey: "department", header: "부서" }]
function PublisherList() {
const [query, setQuery] = useState("")
const [department, setDepartment] = useState("all")
const table = useDataTable({ columns, data: users, getRowId: row => row.id })
const apply = () => { table.setGlobalFilter(query || undefined); table.getColumn("department")?.setFilterValue(department === "all" ? undefined : department) }
const reset = () => { setQuery(""); setDepartment("all"); table.resetGlobalFilter(true); table.resetColumnFilters(true) }
return <div className="publisher-example">
<GNB><GNBMobileMenu title="사용자 메뉴"><GNBMobileLink href="#users" active>사용자 목록</GNBMobileLink></GNBMobileMenu><GNBBrand href="#users"><span><small>HMDesign</small><strong>사용자 관리</strong></span></GNBBrand></GNB>
<main id="users" className="publisher-example-main">
<PageHeader><PageHeaderContent><PageHeaderTitle>사용자 목록</PageHeaderTitle><PageHeaderDescription>부서와 검색어를 입력한 뒤 조회합니다.</PageHeaderDescription></PageHeaderContent></PageHeader>
<Card className="min-w-0"><CardContent><div className="grid min-w-0 gap-4">
<form onSubmit={event => { event.preventDefault(); apply() }}><FilterBar className="hm-list-search-basic">
<Select value={department} onValueChange={setDepartment}><SelectTrigger size="medium" aria-label="부서"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">전체 부서</SelectItem><SelectItem value="디자인">디자인</SelectItem><SelectItem value="개발">개발</SelectItem></SelectContent></Select>
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="이름 검색" />
<FilterBarActions><Button type="submit" variant="primary" appearance="solid">조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button></FilterBarActions>
</FilterBar></form>
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
</div></CardContent></Card>
</main>
</div>
}
createRoot(document.getElementById("root")!).render(<StrictMode><PublisherList /></StrictMode>)
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "tsBuildInfoFile": "../../_local/publisher-example.tsbuildinfo" },
"include": ["main.tsx", "vite.config.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "node:url"
export default defineConfig({ root: fileURLToPath(new URL("./", import.meta.url)), publicDir: fileURLToPath(new URL("../../public", import.meta.url)), base: "./", plugins: [react(), tailwindcss()], server: { host: "127.0.0.1" } })