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; +}