From b3f64647e026417cfd4b88f3a6ab31bb7201cda2 Mon Sep 17 00:00:00 2001 From: bckmini Date: Mon, 20 Jul 2026 11:59:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(design-system):=20PWF=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Figma 디자인 토큰(색상/spacing/radius/폰트)을 src/styles/tokens.css로 정리 - Button(Primary/Secondary), StatusLabel(6톤), AgentSourceLabel(4종) - WorkflowStep(4상태), DecisionGate(3상태), EmptyState(Empty/Loading/Error) - 이후 AUTH/HOME/WORK/CREATE/REVIEW/CASE/PEOPLE/SET/LINK 화면이 공통 사용 Closes #18 --- .../AgentSourceLabel.module.css | 31 +++++++++++ .../AgentSourceLabel.test.tsx | 21 ++++++++ .../ui/AgentSourceLabel/AgentSourceLabel.tsx | 25 +++++++++ src/components/ui/Button/Button.module.css | 30 +++++++++++ src/components/ui/Button/Button.test.tsx | 21 ++++++++ src/components/ui/Button/Button.tsx | 17 +++++++ .../ui/DecisionGate/DecisionGate.module.css | 45 ++++++++++++++++ .../ui/DecisionGate/DecisionGate.test.tsx | 19 +++++++ .../ui/DecisionGate/DecisionGate.tsx | 35 +++++++++++++ .../ui/EmptyState/EmptyState.module.css | 43 ++++++++++++++++ .../ui/EmptyState/EmptyState.test.tsx | 23 +++++++++ src/components/ui/EmptyState/EmptyState.tsx | 28 ++++++++++ .../ui/StatusLabel/StatusLabel.module.css | 41 +++++++++++++++ .../ui/StatusLabel/StatusLabel.test.tsx | 21 ++++++++ src/components/ui/StatusLabel/StatusLabel.tsx | 29 +++++++++++ .../ui/WorkflowStep/WorkflowStep.module.css | 51 +++++++++++++++++++ .../ui/WorkflowStep/WorkflowStep.test.tsx | 19 +++++++ .../ui/WorkflowStep/WorkflowStep.tsx | 32 ++++++++++++ src/main.tsx | 1 + src/styles/tokens.css | 35 +++++++++++++ 20 files changed, 567 insertions(+) create mode 100644 src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css create mode 100644 src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx create mode 100644 src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx create mode 100644 src/components/ui/Button/Button.module.css create mode 100644 src/components/ui/Button/Button.test.tsx create mode 100644 src/components/ui/Button/Button.tsx create mode 100644 src/components/ui/DecisionGate/DecisionGate.module.css create mode 100644 src/components/ui/DecisionGate/DecisionGate.test.tsx create mode 100644 src/components/ui/DecisionGate/DecisionGate.tsx create mode 100644 src/components/ui/EmptyState/EmptyState.module.css create mode 100644 src/components/ui/EmptyState/EmptyState.test.tsx create mode 100644 src/components/ui/EmptyState/EmptyState.tsx create mode 100644 src/components/ui/StatusLabel/StatusLabel.module.css create mode 100644 src/components/ui/StatusLabel/StatusLabel.test.tsx create mode 100644 src/components/ui/StatusLabel/StatusLabel.tsx create mode 100644 src/components/ui/WorkflowStep/WorkflowStep.module.css create mode 100644 src/components/ui/WorkflowStep/WorkflowStep.test.tsx create mode 100644 src/components/ui/WorkflowStep/WorkflowStep.tsx create mode 100644 src/styles/tokens.css diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css b/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css new file mode 100644 index 0000000..d2b20da --- /dev/null +++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.module.css @@ -0,0 +1,31 @@ +.label { + display: inline-flex; + align-items: center; + padding: var(--fowoco-spacing-4) var(--fowoco-spacing-8); + border-radius: var(--fowoco-radius-999); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; +} + +.rule { + background: var(--fowoco-teal-50); + color: var(--brand-primary); +} + +.data { + background: var(--surface-subtle); + color: var(--text-secondary); +} + +.draft { + background: var(--fowoco-violet-50); + color: var(--fowoco-violet-600); +} + +.review { + background: var(--fowoco-amber-50); + color: var(--fowoco-amber-600); +} diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx b/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx new file mode 100644 index 0000000..acc2b72 --- /dev/null +++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { AgentSourceLabel } from './AgentSourceLabel' + +describe('AgentSourceLabel', () => { + it('renders the Korean label for each source', () => { + render( + <> + + + + + , + ) + + expect(screen.getByText('등록된 규칙')).toBeInTheDocument() + expect(screen.getByText('보유 데이터')).toBeInTheDocument() + expect(screen.getByText('Agent 초안')).toBeInTheDocument() + expect(screen.getByText('HR 확인')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx b/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx new file mode 100644 index 0000000..952a247 --- /dev/null +++ b/src/components/ui/AgentSourceLabel/AgentSourceLabel.tsx @@ -0,0 +1,25 @@ +import styles from './AgentSourceLabel.module.css' + +export type AgentSource = 'rule' | 'data' | 'draft' | 'review' + +const SOURCE_LABEL: Record = { + rule: '등록된 규칙', + data: '보유 데이터', + draft: 'Agent 초안', + review: 'HR 확인', +} + +const SOURCE_CLASS: Record = { + rule: styles.rule, + data: styles.data, + draft: styles.draft, + review: styles.review, +} + +export interface AgentSourceLabelProps { + source?: AgentSource +} + +export function AgentSourceLabel({ source = 'rule' }: AgentSourceLabelProps) { + return {SOURCE_LABEL[source]} +} diff --git a/src/components/ui/Button/Button.module.css b/src/components/ui/Button/Button.module.css new file mode 100644 index 0000000..3750422 --- /dev/null +++ b/src/components/ui/Button/Button.module.css @@ -0,0 +1,30 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: var(--fowoco-spacing-8) var(--fowoco-spacing-16); + border-radius: var(--fowoco-radius-6); + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + line-height: 22px; + cursor: pointer; +} + +.primary { + background: var(--brand-primary); + color: var(--fowoco-white); + border: none; +} + +.secondary { + background: var(--surface-default); + color: var(--text-primary); + border: 1px solid var(--border-default); +} + +.button:disabled { + opacity: 0.42; + cursor: not-allowed; +} diff --git a/src/components/ui/Button/Button.test.tsx b/src/components/ui/Button/Button.test.tsx new file mode 100644 index 0000000..21eeda5 --- /dev/null +++ b/src/components/ui/Button/Button.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { Button } from './Button' + +describe('Button', () => { + it('renders its label and fires onClick', async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: '업무 생성' })) + + expect(onClick).toHaveBeenCalledOnce() + }) + + it('is disabled when disabled prop is set', () => { + render() + expect(screen.getByRole('button', { name: '업무 생성' })).toBeDisabled() + }) +}) diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx new file mode 100644 index 0000000..f79bffa --- /dev/null +++ b/src/components/ui/Button/Button.tsx @@ -0,0 +1,17 @@ +import type { ButtonHTMLAttributes } from 'react' +import styles from './Button.module.css' + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' +} + +export function Button({ variant = 'primary', className, type = 'button', ...rest }: ButtonProps) { + const variantClass = variant === 'primary' ? styles.primary : styles.secondary + return ( + + + ) +} diff --git a/src/components/ui/StatusLabel/StatusLabel.module.css b/src/components/ui/StatusLabel/StatusLabel.module.css new file mode 100644 index 0000000..bde94c4 --- /dev/null +++ b/src/components/ui/StatusLabel/StatusLabel.module.css @@ -0,0 +1,41 @@ +.label { + display: inline-flex; + align-items: center; + padding: var(--fowoco-spacing-4) var(--fowoco-spacing-8); + border-radius: var(--fowoco-radius-999); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; +} + +.neutral { + background: var(--surface-subtle); + color: var(--text-secondary); +} + +.info { + background: var(--fowoco-teal-50); + color: var(--brand-primary); +} + +.success { + background: var(--fowoco-green-50); + color: var(--fowoco-green-600); +} + +.warning { + background: var(--fowoco-amber-50); + color: var(--fowoco-amber-600); +} + +.critical { + background: var(--fowoco-red-50); + color: var(--fowoco-red-600); +} + +.agent { + background: var(--fowoco-violet-50); + color: var(--fowoco-violet-600); +} diff --git a/src/components/ui/StatusLabel/StatusLabel.test.tsx b/src/components/ui/StatusLabel/StatusLabel.test.tsx new file mode 100644 index 0000000..221be75 --- /dev/null +++ b/src/components/ui/StatusLabel/StatusLabel.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StatusLabel } from './StatusLabel' + +describe('StatusLabel', () => { + it('renders the Korean label for each tone', () => { + render( + <> + + + + + , + ) + + expect(screen.getByText('진행 중')).toBeInTheDocument() + expect(screen.getByText('확인 필요')).toBeInTheDocument() + expect(screen.getByText('실행 차단')).toBeInTheDocument() + expect(screen.getByText('Agent 준비')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/StatusLabel/StatusLabel.tsx b/src/components/ui/StatusLabel/StatusLabel.tsx new file mode 100644 index 0000000..a2eb3c3 --- /dev/null +++ b/src/components/ui/StatusLabel/StatusLabel.tsx @@ -0,0 +1,29 @@ +import styles from './StatusLabel.module.css' + +export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'critical' | 'agent' + +const TONE_LABEL: Record = { + neutral: '진행 중', + info: '안내', + success: '완료', + warning: '확인 필요', + critical: '실행 차단', + agent: 'Agent 준비', +} + +const TONE_CLASS: Record = { + neutral: styles.neutral, + info: styles.info, + success: styles.success, + warning: styles.warning, + critical: styles.critical, + agent: styles.agent, +} + +export interface StatusLabelProps { + tone?: StatusTone +} + +export function StatusLabel({ tone = 'neutral' }: StatusLabelProps) { + return {TONE_LABEL[tone]} +} diff --git a/src/components/ui/WorkflowStep/WorkflowStep.module.css b/src/components/ui/WorkflowStep/WorkflowStep.module.css new file mode 100644 index 0000000..12e77af --- /dev/null +++ b/src/components/ui/WorkflowStep/WorkflowStep.module.css @@ -0,0 +1,51 @@ +.step { + display: flex; + align-items: center; + gap: var(--fowoco-spacing-12); +} + +.dot { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; +} + +.dotPending { + background: var(--border-default); +} + +.dotActive { + background: var(--brand-primary); +} + +.dotDone { + background: var(--fowoco-green-600); +} + +.dotBlocked { + background: var(--fowoco-red-600); +} + +.label { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.labelPending { + color: var(--text-secondary); +} + +.labelActive { + color: var(--brand-primary); +} + +.labelDone { + color: var(--fowoco-green-600); +} + +.labelBlocked { + color: var(--fowoco-red-600); +} diff --git a/src/components/ui/WorkflowStep/WorkflowStep.test.tsx b/src/components/ui/WorkflowStep/WorkflowStep.test.tsx new file mode 100644 index 0000000..20f506c --- /dev/null +++ b/src/components/ui/WorkflowStep/WorkflowStep.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { WorkflowStep } from './WorkflowStep' + +describe('WorkflowStep', () => { + it('renders the step label for each state', () => { + render( + <> + 근로자에게 서류 요청 + 서류 확인 완료 + 승인이 필요합니다 + , + ) + + expect(screen.getByText('근로자에게 서류 요청')).toBeInTheDocument() + expect(screen.getByText('서류 확인 완료')).toBeInTheDocument() + expect(screen.getByText('승인이 필요합니다')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/WorkflowStep/WorkflowStep.tsx b/src/components/ui/WorkflowStep/WorkflowStep.tsx new file mode 100644 index 0000000..dbb1ed9 --- /dev/null +++ b/src/components/ui/WorkflowStep/WorkflowStep.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' +import styles from './WorkflowStep.module.css' + +export type WorkflowStepState = 'pending' | 'active' | 'done' | 'blocked' + +const DOT_CLASS: Record = { + pending: styles.dotPending, + active: styles.dotActive, + done: styles.dotDone, + blocked: styles.dotBlocked, +} + +const LABEL_CLASS: Record = { + pending: styles.labelPending, + active: styles.labelActive, + done: styles.labelDone, + blocked: styles.labelBlocked, +} + +export interface WorkflowStepProps { + state?: WorkflowStepState + children: ReactNode +} + +export function WorkflowStep({ state = 'pending', children }: WorkflowStepProps) { + return ( +
+
+ ) +} diff --git a/src/main.tsx b/src/main.tsx index 4c3176a..ddefafc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from 'react-router-dom' +import './styles/tokens.css' import './index.css' import { queryClient } from './lib/queryClient' import { router } from './routes' diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..e513db9 --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,35 @@ +:root { + --brand-primary: #07847f; + + --surface-default: #fff; + --surface-subtle: #eef4f4; + --border-default: #dce5e4; + + --text-primary: #172b2d; + --text-secondary: #5f6f72; + + --fowoco-white: #fff; + --fowoco-teal-50: #eef4f4; + --fowoco-violet-50: #f4f0ff; + --fowoco-violet-600: #7657c8; + --fowoco-red-50: #fff1f1; + --fowoco-red-600: #d84a4a; + --fowoco-amber-50: #fff7e8; + --fowoco-amber-600: #d79222; + --fowoco-green-50: #ecf8f1; + --fowoco-green-600: #2c8a5b; + + --status-critical: #d84a4a; + + --fowoco-spacing-4: 4px; + --fowoco-spacing-8: 8px; + --fowoco-spacing-12: 12px; + --fowoco-spacing-16: 16px; + --fowoco-spacing-24: 24px; + + --fowoco-radius-6: 6px; + --fowoco-radius-8: 8px; + --fowoco-radius-999: 999px; + + --font-sans: 'Noto Sans KR', system-ui, 'Segoe UI', Roboto, sans-serif; +}