상태 표시를 각진 태그 하나로 통일
색만 달랐던 알약 배지는 흑백 출력과 색약 상황에서 "운영 중"과 "개방불가"가 똑같이 보였다. 모양이 상태를 함께 담도록 바꾼다. - ui/StatusGlyph: 90도와 45도만 쓴 10px 글리프 8종 - ui/StatusChip: 오른쪽 위 모서리를 잘라낸 태그 + 왼쪽 2px 컬러 바. 표에서 바가 세로로 정렬돼 목록을 훑기 쉬워진다 - codes/tone: tone에서 칩 클래스와 기본 글리프를 파생 적용: 채널 준비 상태, 권리확인/권리처리 판정, 담당자 구분, 단계 태그, 타임라인(이모지 제거), 업무단계 스트립 완료 표시(글꼴 체크 제거 - OS마다 굵기와 곡률이 달랐다) 필터 칩과 아바타는 둥근 모양 그대로 뒀다. 둥글면 누를 수 있는 것, 각지면 사실을 알려주는 것으로 구분된다. Co-Authored-By: Claude Opus 5 (1M context)
@ecde204b49e2a916965e0f986e3ed2f97b842ca0
--- frontend/src/codes/tone.ts
+++ frontend/src/codes/tone.ts
... | ... | @@ -8,7 +8,9 @@ |
| 8 | 8 |
* 못 찾아 지워버리기 때문이다. 반드시 문자열 전체가 소스에 보여야 한다. |
| 9 | 9 |
*/ |
| 10 | 10 |
|
| 11 |
-/** 뱃지: 옅은 배경 + 진한 글씨. 처리결과·담당자 구분이 쓴다. */ |
|
| 11 |
+import type { GlyphKind } from '../ui/StatusGlyph'
|
|
| 12 |
+ |
|
| 13 |
+/** 뱃지: 옅은 배경 + 진한 글씨. 예전 표기. 새 화면은 StatusChip을 쓴다. */ |
|
| 12 | 14 |
const BADGE: Record<string, string> = {
|
| 13 | 15 |
slate: 'bg-slate-100 text-slate-700', |
| 14 | 16 |
gray: 'bg-gray-100 text-gray-700', |
... | ... | @@ -50,3 +52,49 @@ |
| 50 | 52 |
export function barClass(tone: string | null | undefined): string {
|
| 51 | 53 |
return (tone && BAR[tone]) || BAR_FALLBACK |
| 52 | 54 |
} |
| 55 |
+ |
|
| 56 |
+/** |
|
| 57 |
+ * 칩: 왼쪽 컬러 바 + 옅은 배경 + 진한 글씨. StatusChip이 쓴다. |
|
| 58 |
+ * |
|
| 59 |
+ * 배경만 칠하던 예전 뱃지와 달리 왼쪽에 2px 바를 세우는 이유는, 표에서 상태 칸이 |
|
| 60 |
+ * 세로로 늘어설 때 그 바들이 한 줄로 정렬돼 눈이 빠르게 훑고 내려갈 수 있어서다. |
|
| 61 |
+ */ |
|
| 62 |
+const CHIP: Record<string, string> = {
|
|
| 63 |
+ slate: 'border-slate-400 bg-slate-50 text-slate-700', |
|
| 64 |
+ gray: 'border-gray-400 bg-gray-50 text-gray-700', |
|
| 65 |
+ blue: 'border-blue-500 bg-blue-50 text-blue-800', |
|
| 66 |
+ sky: 'border-sky-500 bg-sky-50 text-sky-800', |
|
| 67 |
+ indigo: 'border-indigo-500 bg-indigo-50 text-indigo-800', |
|
| 68 |
+ violet: 'border-violet-500 bg-violet-50 text-violet-800', |
|
| 69 |
+ emerald: 'border-emerald-600 bg-emerald-50 text-emerald-800', |
|
| 70 |
+ amber: 'border-amber-500 bg-amber-50 text-amber-800', |
|
| 71 |
+ orange: 'border-orange-500 bg-orange-50 text-orange-800', |
|
| 72 |
+ red: 'border-red-500 bg-red-50 text-red-800', |
|
| 73 |
+} |
|
| 74 |
+ |
|
| 75 |
+const CHIP_FALLBACK = 'border-gray-300 bg-gray-50 text-gray-600' |
|
| 76 |
+ |
|
| 77 |
+/** |
|
| 78 |
+ * tone이 정해지면 글리프 모양도 따라 정해진다. 코드표에 색만 지정해도 알맞은 모양이 |
|
| 79 |
+ * 붙게 하려는 것이다 - 코드 한 줄 추가할 때마다 모양까지 고르게 하면 금방 어긋난다. |
|
| 80 |
+ */ |
|
| 81 |
+const GLYPH: Record<string, GlyphKind> = {
|
|
| 82 |
+ emerald: 'done', |
|
| 83 |
+ blue: 'progress', |
|
| 84 |
+ sky: 'progress', |
|
| 85 |
+ indigo: 'progress', |
|
| 86 |
+ violet: 'progress', |
|
| 87 |
+ amber: 'caution', |
|
| 88 |
+ orange: 'caution', |
|
| 89 |
+ red: 'blocked', |
|
| 90 |
+ slate: 'idle', |
|
| 91 |
+ gray: 'idle', |
|
| 92 |
+} |
|
| 93 |
+ |
|
| 94 |
+export function chipClass(tone: string | null | undefined): string {
|
|
| 95 |
+ return (tone && CHIP[tone]) || CHIP_FALLBACK |
|
| 96 |
+} |
|
| 97 |
+ |
|
| 98 |
+export function glyphOf(tone: string | null | undefined): GlyphKind {
|
|
| 99 |
+ return (tone && GLYPH[tone]) || 'idle' |
|
| 100 |
+} |
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 |
type MemberImportReport, |
| 12 | 12 |
} from '../api/client' |
| 13 | 13 |
import { useCodes } from '../codes/useCodes'
|
| 14 |
-import { badgeClass } from '../codes/tone'
|
|
| 14 |
+import StatusChip from '../ui/StatusChip' |
|
| 15 | 15 |
import HelpButton from '../help/HelpButton' |
| 16 | 16 |
|
| 17 | 17 |
// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다. |
... | ... | @@ -391,13 +391,11 @@ |
| 391 | 391 |
visible.map((contact) => ( |
| 392 | 392 |
<tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
|
| 393 | 393 |
<td className="px-3 py-2"> |
| 394 |
- <span |
|
| 395 |
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
|
|
| 396 |
- categoryOf(contact.category)?.attrs.tone as string | undefined, |
|
| 397 |
- )}`} |
|
| 398 |
- > |
|
| 399 |
- {categoryOf(contact.category)?.label ?? contact.category}
|
|
| 400 |
- </span> |
|
| 394 |
+ <StatusChip |
|
| 395 |
+ label={categoryOf(contact.category)?.label ?? contact.category}
|
|
| 396 |
+ tone={categoryOf(contact.category)?.attrs.tone as string | undefined}
|
|
| 397 |
+ glyph="note" |
|
| 398 |
+ /> |
|
| 401 | 399 |
</td> |
| 402 | 400 |
<td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
|
| 403 | 401 |
<td className="px-3 py-2 text-gray-600">{contact.affiliation ?? '-'}</td>
|
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
} from '../api/client' |
| 8 | 8 |
import { useStages } from '../codes/stage'
|
| 9 | 9 |
import { barClass } from '../codes/tone'
|
| 10 |
+import StatusChip from '../ui/StatusChip' |
|
| 10 | 11 |
import HelpButton from '../help/HelpButton' |
| 11 | 12 |
|
| 12 | 13 |
/** |
... | ... | @@ -163,9 +164,7 @@ |
| 163 | 164 |
return ( |
| 164 | 165 |
<div className="space-y-1 text-xs text-gray-500"> |
| 165 | 166 |
<p>{lawyer}</p>
|
| 166 |
- <span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700"> |
|
| 167 |
- {stages.symbol(stage)} {stages.label(stage)}
|
|
| 168 |
- </span> |
|
| 167 |
+ <StatusChip label={stages.label(stage)} tone="violet" prefix={stages.symbol(stage)} />
|
|
| 169 | 168 |
</div> |
| 170 | 169 |
) |
| 171 | 170 |
} |
... | ... | @@ -614,9 +613,15 @@ |
| 614 | 613 |
<td className="px-2 py-2 text-gray-500 tabular-nums">{row.orgNo}</td>
|
| 615 | 614 |
<td className="px-2 py-2 font-medium">{row.orgName}</td>
|
| 616 | 615 |
<td className="px-2 py-2"> |
| 617 |
- <span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700"> |
|
| 618 |
- {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
|
|
| 619 |
- </span> |
|
| 616 |
+ {stage === null ? (
|
|
| 617 |
+ <StatusChip label="단계 미지정" tone="gray" glyph="idle" /> |
|
| 618 |
+ ) : ( |
|
| 619 |
+ <StatusChip |
|
| 620 |
+ label={stages.label(stage)}
|
|
| 621 |
+ tone="violet" |
|
| 622 |
+ prefix={stages.symbol(stage)}
|
|
| 623 |
+ /> |
|
| 624 |
+ )} |
|
| 620 | 625 |
</td> |
| 621 | 626 |
<td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
|
| 622 | 627 |
<td className="px-2 py-2 text-gray-600">{row.lawyerName ?? '-'}</td>
|
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -402,7 +402,9 @@ |
| 402 | 402 |
const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
|
| 403 | 403 |
const futureButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
|
| 404 | 404 |
|
| 405 |
- expect(within(doneButton).getByText('✓')).toBeTruthy()
|
|
| 405 |
+ // 완료 단계는 단계 번호 대신 체크 글리프(svg)를 보여준다. |
|
| 406 |
+ expect(doneButton.querySelector('svg')).toBeTruthy()
|
|
| 407 |
+ expect(within(doneButton).queryByText('1')).toBeNull()
|
|
| 406 | 408 |
expect(within(currentButton).getByText('진행중')).toBeTruthy()
|
| 407 | 409 |
expect(within(futureButton).getByText('-')).toBeTruthy()
|
| 408 | 410 |
expect(futureButton).toBeDisabled() |
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 |
type PostView, |
| 13 | 13 |
} from '../api/client' |
| 14 | 14 |
import { useStages } from '../codes/stage'
|
| 15 |
+import StatusGlyph from '../ui/StatusGlyph' |
|
| 15 | 16 |
import HelpButton from '../help/HelpButton' |
| 16 | 17 |
import ChannelFiles from './ChannelFiles' |
| 17 | 18 |
import ChannelPosts from './ChannelPosts' |
... | ... | @@ -467,7 +468,7 @@ |
| 467 | 468 |
} ${clickable ? 'cursor-pointer hover:bg-blue-100' : 'cursor-default disabled:opacity-100'}`}
|
| 468 | 469 |
> |
| 469 | 470 |
<span |
| 470 |
- className={`flex h-6 w-6 items-center justify-center rounded-full border text-xs ${
|
|
| 471 |
+ className={`flex h-6 w-6 items-center justify-center border text-xs tabular-nums ${
|
|
| 471 | 472 |
isDone |
| 472 | 473 |
? 'border-emerald-500 bg-emerald-500 text-white' |
| 473 | 474 |
: isCurrent |
... | ... | @@ -475,7 +476,8 @@ |
| 475 | 476 |
: 'border-gray-300 text-gray-500' |
| 476 | 477 |
}`} |
| 477 | 478 |
> |
| 478 |
- {isDone ? '✓' : n}
|
|
| 479 |
+ {/* 완료 표시는 글꼴의 ✓ 대신 각진 글리프를 쓴다. ✓는 OS마다 굵기와 곡률이 달라진다. */}
|
|
| 480 |
+ {isDone ? <StatusGlyph kind="check" size={12} /> : n}
|
|
| 479 | 481 |
</span> |
| 480 | 482 |
<span |
| 481 | 483 |
className={`text-[11px] leading-tight ${
|
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
type ProcessStatusFilter, |
| 16 | 16 |
type ProcessingRequest, |
| 17 | 17 |
} from '../api/client' |
| 18 |
+import StatusChip from '../ui/StatusChip' |
|
| 18 | 19 |
import { useCodes, useSplitByFirstRow } from '../codes/useCodes'
|
| 19 | 20 |
|
| 20 | 21 |
const PAGE_SIZE = 30 |
... | ... | @@ -173,13 +174,11 @@ |
| 173 | 174 |
const done = !!doneCode && value === doneCode.code |
| 174 | 175 |
const pendingLabel = statuses.find((status) => status.attrs.done !== true)?.label ?? '' |
| 175 | 176 |
return ( |
| 176 |
- <span |
|
| 177 |
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
| 178 |
- done ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' |
|
| 179 |
- }`} |
|
| 180 |
- > |
|
| 181 |
- {done ? doneCode.label : pendingLabel}
|
|
| 182 |
- </span> |
|
| 177 |
+ <StatusChip |
|
| 178 |
+ label={done ? doneCode.label : pendingLabel}
|
|
| 179 |
+ tone={done ? 'emerald' : 'gray'}
|
|
| 180 |
+ glyph={done ? 'done' : 'idle'}
|
|
| 181 |
+ /> |
|
| 183 | 182 |
) |
| 184 | 183 |
} |
| 185 | 184 |
|
--- frontend/src/components/ProjectsPage.test.tsx
+++ frontend/src/components/ProjectsPage.test.tsx
... | ... | @@ -100,7 +100,9 @@ |
| 100 | 100 |
|
| 101 | 101 |
const table = within(await screen.findByTestId('project-table'))
|
| 102 | 102 |
expect(table.getByText('국제방송교류재단')).toBeTruthy()
|
| 103 |
- expect(table.getByText('5. 법률검토(권리확인)')).toBeTruthy()
|
|
| 103 |
+ // 단계 태그는 번호와 이름이 각각 다른 span이다(번호는 tabular-nums로 세로 정렬된다). |
|
| 104 |
+ expect(table.getByText('5.')).toBeTruthy()
|
|
| 105 |
+ expect(table.getByText('법률검토(권리확인)')).toBeTruthy()
|
|
| 104 | 106 |
expect(table.getByText('2026-07-20')).toBeTruthy()
|
| 105 | 107 |
expect(table.getByText('(3)')).toBeTruthy()
|
| 106 | 108 |
}) |
--- frontend/src/components/ProjectsPage.tsx
+++ frontend/src/components/ProjectsPage.tsx
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
} from '../api/client' |
| 16 | 16 |
import { useStages } from '../codes/stage'
|
| 17 | 17 |
import { useCodes } from '../codes/useCodes'
|
| 18 |
+import StatusChip from '../ui/StatusChip' |
|
| 18 | 19 |
import HelpButton from '../help/HelpButton' |
| 19 | 20 |
|
| 20 | 21 |
/** |
... | ... | @@ -300,9 +301,15 @@ |
| 300 | 301 |
</button> |
| 301 | 302 |
</td> |
| 302 | 303 |
<td className="px-2 py-2 text-gray-600"> |
| 303 |
- {row.org.stage === null
|
|
| 304 |
- ? '단계 미지정' |
|
| 305 |
- : `${row.org.stage}. ${stages.label(row.org.stage)}`}
|
|
| 304 |
+ {row.org.stage === null ? (
|
|
| 305 |
+ <StatusChip label="단계 미지정" tone="gray" glyph="idle" /> |
|
| 306 |
+ ) : ( |
|
| 307 |
+ <StatusChip |
|
| 308 |
+ label={stages.label(row.org.stage)}
|
|
| 309 |
+ tone="violet" |
|
| 310 |
+ prefix={`${row.org.stage}.`}
|
|
| 311 |
+ /> |
|
| 312 |
+ )} |
|
| 306 | 313 |
</td> |
| 307 | 314 |
<td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
|
| 308 | 315 |
<td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
|
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
... | ... | @@ -14,7 +14,7 @@ |
| 14 | 14 |
type ReviewItem, |
| 15 | 15 |
} from '../api/client' |
| 16 | 16 |
import { NOTE_NO_PROCESSING } from '../codes/messages'
|
| 17 |
-import { badgeClass } from '../codes/tone'
|
|
| 17 |
+import StatusChip from '../ui/StatusChip' |
|
| 18 | 18 |
import { attr, useCodes, useScopedCodes, useSplitByFirstRow } from '../codes/useCodes'
|
| 19 | 19 |
|
| 20 | 20 |
const PAGE_SIZE = 30 |
... | ... | @@ -160,17 +160,13 @@ |
| 160 | 160 |
} |
| 161 | 161 |
|
| 162 | 162 |
function ReviewResultBadge({ value }: { value: string | null }) {
|
| 163 |
- // 뱃지 색은 코드의 tone에서 온다. 코드표에 없는 값이면 badgeClass가 회색으로 떨어뜨린다. |
|
| 163 |
+ // 색과 모양이 모두 코드의 tone에서 파생된다. 코드표에 없는 값이면 회색 빈 사각으로 떨어진다. |
|
| 164 | 164 |
const results = useCodes('REVIEW_RESULT')
|
| 165 | 165 |
if (!value) {
|
| 166 |
- return ( |
|
| 167 |
- <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">미판정</span> |
|
| 168 |
- ) |
|
| 166 |
+ return <StatusChip label="미판정" tone="gray" glyph="idle" /> |
|
| 169 | 167 |
} |
| 170 | 168 |
const tone = attr(results.find((candidate) => candidate.code === value), 'tone') |
| 171 |
- return ( |
|
| 172 |
- <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(tone)}`}>{value}</span>
|
|
| 173 |
- ) |
|
| 169 |
+ return <StatusChip label={value} tone={tone} />
|
|
| 174 | 170 |
} |
| 175 | 171 |
|
| 176 | 172 |
/** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */ |
--- frontend/src/components/StatusBadge.tsx
+++ frontend/src/components/StatusBadge.tsx
... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 |
import type { OrgStatus } from '../api/client'
|
| 2 |
+import StatusChip from '../ui/StatusChip' |
|
| 3 |
+import type { GlyphKind } from '../ui/StatusGlyph'
|
|
| 2 | 4 |
|
| 3 | 5 |
export const STATUS_LABELS: Record<OrgStatus, string> = {
|
| 4 | 6 |
INFO_PENDING: '정보 대기', |
... | ... | @@ -7,7 +9,7 @@ |
| 7 | 9 |
ACTIVE: '운영 중', |
| 8 | 10 |
} |
| 9 | 11 |
|
| 10 |
-/** 접힌 목록 등 좁은 자리에서 뱃지 대신 쓰는 상태 점 색 */ |
|
| 12 |
+/** 접힌 목록 등 뱃지를 놓을 자리가 없을 때 쓰는 상태 점 색 */ |
|
| 11 | 13 |
export const STATUS_DOT: Record<OrgStatus, string> = {
|
| 12 | 14 |
INFO_PENDING: 'bg-gray-300', |
| 13 | 15 |
READY: 'bg-blue-500', |
... | ... | @@ -15,19 +17,24 @@ |
| 15 | 17 |
ACTIVE: 'bg-emerald-500', |
| 16 | 18 |
} |
| 17 | 19 |
|
| 18 |
-const LABELS = STATUS_LABELS |
|
| 20 |
+/** |
|
| 21 |
+ * 채널 준비 상태는 네 단계가 순서대로 이어진다. 그래서 색뿐 아니라 모양도 그 순서를 담는다. |
|
| 22 |
+ * 빈 사각(담당자 없음) → 절반(생성 가능) → 삼각(한쪽만 생성돼 손봐야 함) → 채운 사각(운영 중). |
|
| 23 |
+ */ |
|
| 24 |
+const TONE: Record<OrgStatus, string> = {
|
|
| 25 |
+ INFO_PENDING: 'gray', |
|
| 26 |
+ READY: 'blue', |
|
| 27 |
+ PARTIAL: 'amber', |
|
| 28 |
+ ACTIVE: 'emerald', |
|
| 29 |
+} |
|
| 19 | 30 |
|
| 20 |
-const STYLES: Record<OrgStatus, string> = {
|
|
| 21 |
- INFO_PENDING: 'bg-gray-100 text-gray-600', |
|
| 22 |
- READY: 'bg-blue-50 text-blue-700', |
|
| 23 |
- PARTIAL: 'bg-amber-50 text-amber-700', |
|
| 24 |
- ACTIVE: 'bg-emerald-50 text-emerald-700', |
|
| 31 |
+const GLYPH: Record<OrgStatus, GlyphKind> = {
|
|
| 32 |
+ INFO_PENDING: 'idle', |
|
| 33 |
+ READY: 'progress', |
|
| 34 |
+ PARTIAL: 'caution', |
|
| 35 |
+ ACTIVE: 'done', |
|
| 25 | 36 |
} |
| 26 | 37 |
|
| 27 | 38 |
export default function StatusBadge({ status }: { status: OrgStatus }) {
|
| 28 |
- return ( |
|
| 29 |
- <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[status]}`}>
|
|
| 30 |
- {LABELS[status]}
|
|
| 31 |
- </span> |
|
| 32 |
- ) |
|
| 39 |
+ return <StatusChip label={STATUS_LABELS[status]} tone={TONE[status]} glyph={GLYPH[status]} />
|
|
| 33 | 40 |
} |
--- frontend/src/components/Timeline.tsx
+++ frontend/src/components/Timeline.tsx
... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 |
import { useEffect, useState } from 'react'
|
| 2 | 2 |
import { getTimeline, type Org, type TimelineEvent } from '../api/client'
|
| 3 | 3 |
import { useStages, type Stages } from '../codes/stage'
|
| 4 |
+import StatusChip from '../ui/StatusChip' |
|
| 5 |
+import StatusGlyph, { type GlyphKind } from '../ui/StatusGlyph'
|
|
| 4 | 6 |
|
| 5 | 7 |
const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
|
| 6 | 8 |
year: 'numeric', |
... | ... | @@ -55,10 +57,18 @@ |
| 55 | 57 |
return groups |
| 56 | 58 |
} |
| 57 | 59 |
|
| 58 |
-const EVENT_META: Record<TimelineEvent['type'], { dot: string; icon: string; badge: string; badgeClass: string }> = {
|
|
| 59 |
- STAGE: { dot: 'bg-blue-600', icon: '🚩', badge: '단계 변경', badgeClass: 'bg-blue-50 text-blue-700' },
|
|
| 60 |
- FILE: { dot: 'bg-amber-500', icon: '📎', badge: '자료 등록', badgeClass: 'bg-amber-50 text-amber-700' },
|
|
| 61 |
- MEMO: { dot: 'bg-emerald-600', icon: '✏️', badge: '업무메모', badgeClass: 'bg-emerald-50 text-emerald-700' },
|
|
| 60 |
+/** |
|
| 61 |
+ * 이벤트 종류별 색·글리프. 이모지를 쓰지 않는 이유는 두 가지다. 하나는 OS마다 그림이 달라 |
|
| 62 |
+ * 같은 화면이 사람마다 다르게 보인다는 것, 다른 하나는 둥근 이모지가 이 화면의 각진 상태 |
|
| 63 |
+ * 태그와 어울리지 않는다는 것이다. 대신 같은 글리프 세트를 쓴다. |
|
| 64 |
+ */ |
|
| 65 |
+const EVENT_META: Record< |
|
| 66 |
+ TimelineEvent['type'], |
|
| 67 |
+ { dot: string; glyph: GlyphKind; badge: string; tone: string }
|
|
| 68 |
+> = {
|
|
| 69 |
+ STAGE: { dot: 'bg-blue-600', glyph: 'progress', badge: '단계 변경', tone: 'blue' },
|
|
| 70 |
+ FILE: { dot: 'bg-amber-500', glyph: 'document', badge: '자료 등록', tone: 'amber' },
|
|
| 71 |
+ MEMO: { dot: 'bg-emerald-600', glyph: 'note', badge: '업무메모', tone: 'emerald' },
|
|
| 62 | 72 |
} |
| 63 | 73 |
|
| 64 | 74 |
/** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */ |
... | ... | @@ -160,15 +170,13 @@ |
| 160 | 170 |
<li key={`${group.key}-${i}`} className="relative">
|
| 161 | 171 |
<span |
| 162 | 172 |
aria-hidden="true" |
| 163 |
- className={`absolute -left-[1.65rem] flex h-5 w-5 items-center justify-center rounded-full text-[10px] leading-none text-white ${meta.dot}`}
|
|
| 173 |
+ className={`absolute -left-[1.6rem] flex h-[18px] w-[18px] items-center justify-center text-white ${meta.dot}`}
|
|
| 164 | 174 |
> |
| 165 |
- {meta.icon}
|
|
| 175 |
+ <StatusGlyph kind={meta.glyph} />
|
|
| 166 | 176 |
</span> |
| 167 | 177 |
<div className="flex items-baseline gap-2"> |
| 168 | 178 |
<span className="text-xs tabular-nums text-gray-400">{formatTime(event.at)}</span>
|
| 169 |
- <span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${meta.badgeClass}`}>
|
|
| 170 |
- {meta.badge}
|
|
| 171 |
- </span> |
|
| 179 |
+ <StatusChip label={meta.badge} tone={meta.tone} glyph={meta.glyph} />
|
|
| 172 | 180 |
</div> |
| 173 | 181 |
<div className="mt-1"> |
| 174 | 182 |
<EventBody event={event} />
|
+++ frontend/src/ui/StatusChip.tsx
... | ... | @@ -0,0 +1,45 @@ |
| 1 | +import StatusGlyph, { type GlyphKind } from './StatusGlyph' | |
| 2 | +import { chipClass, glyphOf } from '../codes/tone' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 상태를 나타내는 태그. 화면 전체가 이 하나만 쓴다. | |
| 6 | + * | |
| 7 | + * 생김새의 근거: 오른쪽 위 모서리를 잘라낸 각진 태그 + 왼쪽 컬러 바. 서류 모서리와 | |
| 8 | + * 서식의 항목 표시에서 가져왔다. 알약(rounded-full)을 쓰지 않는 이유는 두 가지다. | |
| 9 | + * 1) 표 안에서 왼쪽 바가 세로로 정렬돼 눈이 한 줄로 훑고 내려갈 수 있다. | |
| 10 | + * 2) 이 화면의 모든 상태가 같은 모양이면 색이 유일한 단서가 된다. 모양(글리프)이 | |
| 11 | + * 상태를 함께 담아야 색약 사용자와 흑백 출력에서도 구분된다. | |
| 12 | + */ | |
| 13 | + | |
| 14 | +interface Props { | |
| 15 | + /** 화면에 보이는 글자. 접근성 이름도 이 값이다. */ | |
| 16 | + label: string | |
| 17 | + /** 코드표의 tone. 색과 글리프가 여기서 파생된다. */ | |
| 18 | + tone?: string | null | |
| 19 | + /** tone에서 파생된 글리프를 쓰지 않고 직접 지정할 때. */ | |
| 20 | + glyph?: GlyphKind | |
| 21 | + /** 글리프를 빼고 글자만 보여준다(자리가 아주 좁은 칸). */ | |
| 22 | + bare?: boolean | |
| 23 | + /** 앞에 붙는 작은 식별자. 단계 번호(①) 같은 것. */ | |
| 24 | + prefix?: string | |
| 25 | + className?: string | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** 오른쪽 위 5px를 잘라낸 모서리. 이 태그의 표식이다. */ | |
| 29 | +const NOTCH = { clipPath: 'polygon(0 0, calc(100% - 5px) 0, 100% 5px, 100% 100%, 0 100%)' } | |
| 30 | + | |
| 31 | +export default function StatusChip({ label, tone, glyph, bare, prefix, className = '' }: Props) { | |
| 32 | + const kind = glyph ?? glyphOf(tone) | |
| 33 | + return ( | |
| 34 | + <span | |
| 35 | + style={NOTCH} | |
| 36 | + className={`inline-flex items-center gap-1.5 border-l-2 py-[3px] pl-1.5 pr-2 align-middle text-[11px] font-semibold leading-none tracking-tight ${chipClass( | |
| 37 | + tone, | |
| 38 | + )} ${className}`} | |
| 39 | + > | |
| 40 | + {!bare && <StatusGlyph kind={kind} />} | |
| 41 | + {prefix && <span className="tabular-nums opacity-70">{prefix}</span>} | |
| 42 | + {label} | |
| 43 | + </span> | |
| 44 | + ) | |
| 45 | +} |
+++ frontend/src/ui/StatusGlyph.tsx
... | ... | @@ -0,0 +1,128 @@ |
| 1 | +/** | |
| 2 | + * 상태 글리프. 10×10 정사각 격자 위에 90°와 45°만 써서 그린다 - 곡선을 하나도 넣지 않은 것은 | |
| 3 | + * 이 화면들이 법률 서식(체크칸·도장·서류 모서리)의 어휘를 따르기 때문이다. | |
| 4 | + * | |
| 5 | + * 모양 자체가 상태를 담는다는 점이 핵심이다. 예전에는 색만 달랐던 탓에, 색약 사용자나 | |
| 6 | + * 흑백 출력에서는 "완료"와 "개방불가"가 똑같이 보였다. | |
| 7 | + * | |
| 8 | + * 색은 넣지 않는다. currentColor를 그대로 물려받아 감싼 칩의 글자색을 따른다. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export type GlyphKind = | |
| 12 | + /** 아직 손대지 않음 - 빈 사각 */ | |
| 13 | + | 'idle' | |
| 14 | + /** 진행 중 - 왼쪽 절반만 채운 사각 */ | |
| 15 | + | 'progress' | |
| 16 | + /** 끝남 - 채운 사각에 각진 체크 */ | |
| 17 | + | 'done' | |
| 18 | + /** 확인이 필요함 - 삼각 */ | |
| 19 | + | 'caution' | |
| 20 | + /** 막힘·불가 - 사각을 가로지르는 사선 */ | |
| 21 | + | 'blocked' | |
| 22 | + /** 자료·문서 - 모서리를 접은 사각 */ | |
| 23 | + | 'document' | |
| 24 | + /** 메모 - 가로줄 세 개 */ | |
| 25 | + | 'note' | |
| 26 | + /** 끝난 항목 표시 - 상자 없는 각진 체크. 이미 색을 채운 자리 위에 얹을 때 쓴다. */ | |
| 27 | + | 'check' | |
| 28 | + | |
| 29 | +interface Props { | |
| 30 | + kind: GlyphKind | |
| 31 | + /** 픽셀 크기. 표 안에서는 10, 제목 옆에서는 12를 쓴다. */ | |
| 32 | + size?: number | |
| 33 | + className?: string | |
| 34 | +} | |
| 35 | + | |
| 36 | +export default function StatusGlyph({ kind, size = 10, className = '' }: Props) { | |
| 37 | + const common = { | |
| 38 | + width: size, | |
| 39 | + height: size, | |
| 40 | + viewBox: '0 0 10 10', | |
| 41 | + // 상태 이름은 옆의 글자가 이미 말해 준다. 보조기술이 같은 말을 두 번 읽지 않게 감춘다. | |
| 42 | + 'aria-hidden': true, | |
| 43 | + focusable: false, | |
| 44 | + className: `shrink-0 ${className}`, | |
| 45 | + } as const | |
| 46 | + | |
| 47 | + const stroke = { | |
| 48 | + stroke: 'currentColor', | |
| 49 | + strokeWidth: 1.6, | |
| 50 | + strokeLinecap: 'square', | |
| 51 | + strokeLinejoin: 'miter', | |
| 52 | + fill: 'none', | |
| 53 | + } as const | |
| 54 | + | |
| 55 | + switch (kind) { | |
| 56 | + case 'idle': | |
| 57 | + return ( | |
| 58 | + <svg {...common}> | |
| 59 | + <rect x="1.6" y="1.6" width="6.8" height="6.8" {...stroke} /> | |
| 60 | + </svg> | |
| 61 | + ) | |
| 62 | + | |
| 63 | + case 'progress': | |
| 64 | + return ( | |
| 65 | + <svg {...common}> | |
| 66 | + <rect x="1.6" y="1.6" width="6.8" height="6.8" {...stroke} /> | |
| 67 | + <rect x="1.6" y="1.6" width="3.4" height="6.8" fill="currentColor" /> | |
| 68 | + </svg> | |
| 69 | + ) | |
| 70 | + | |
| 71 | + case 'done': | |
| 72 | + return ( | |
| 73 | + <svg {...common}> | |
| 74 | + <rect x="1" y="1" width="8" height="8" fill="currentColor" /> | |
| 75 | + <polyline | |
| 76 | + points="2.9,5.1 4.3,6.5 7.1,3.4" | |
| 77 | + stroke="#fff" | |
| 78 | + strokeWidth="1.5" | |
| 79 | + strokeLinecap="square" | |
| 80 | + strokeLinejoin="miter" | |
| 81 | + fill="none" | |
| 82 | + /> | |
| 83 | + </svg> | |
| 84 | + ) | |
| 85 | + | |
| 86 | + case 'caution': | |
| 87 | + return ( | |
| 88 | + <svg {...common}> | |
| 89 | + <polygon points="5,1 9.2,8.6 0.8,8.6" {...stroke} /> | |
| 90 | + <line x1="5" y1="4" x2="5" y2="6.2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" /> | |
| 91 | + <line x1="5" y1="7.2" x2="5" y2="7.3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" /> | |
| 92 | + </svg> | |
| 93 | + ) | |
| 94 | + | |
| 95 | + case 'blocked': | |
| 96 | + return ( | |
| 97 | + <svg {...common}> | |
| 98 | + <rect x="1.6" y="1.6" width="6.8" height="6.8" {...stroke} /> | |
| 99 | + <line x1="2.4" y1="7.6" x2="7.6" y2="2.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" /> | |
| 100 | + </svg> | |
| 101 | + ) | |
| 102 | + | |
| 103 | + case 'document': | |
| 104 | + return ( | |
| 105 | + <svg {...common}> | |
| 106 | + {/* 오른쪽 위 모서리를 접은 종이 */} | |
| 107 | + <polygon points="1.8,1 6.4,1 8.4,3 8.4,9 1.8,9" {...stroke} /> | |
| 108 | + <polyline points="6.4,1 6.4,3 8.4,3" {...stroke} /> | |
| 109 | + </svg> | |
| 110 | + ) | |
| 111 | + | |
| 112 | + case 'note': | |
| 113 | + return ( | |
| 114 | + <svg {...common}> | |
| 115 | + <line x1="1.4" y1="2.6" x2="8.6" y2="2.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" /> | |
| 116 | + <line x1="1.4" y1="5" x2="8.6" y2="5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" /> | |
| 117 | + <line x1="1.4" y1="7.4" x2="5.8" y2="7.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" /> | |
| 118 | + </svg> | |
| 119 | + ) | |
| 120 | + | |
| 121 | + case 'check': | |
| 122 | + return ( | |
| 123 | + <svg {...common}> | |
| 124 | + <polyline points="1.4,5 3.9,7.6 8.6,2.4" {...stroke} strokeWidth={1.8} /> | |
| 125 | + </svg> | |
| 126 | + ) | |
| 127 | + } | |
| 128 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?