refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가
- 입금관리, 출금관리 리스트에 등록 버튼 추가 - skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가 - document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등) - 여러 페이지 컴포넌트 리팩토링 및 코드 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
546
claudedocs/[PLAN-2026-01-22] ui-component-abstraction.md
Normal file
546
claudedocs/[PLAN-2026-01-22] ui-component-abstraction.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# UI 컴포넌트 공통화/추상화 계획
|
||||
|
||||
> **작성일**: 2026-01-22
|
||||
> **상태**: 🟢 진행 중
|
||||
> **범위**: 공통 UI 컴포넌트 추상화 및 스켈레톤 시스템 구축
|
||||
|
||||
---
|
||||
|
||||
## 결정 사항 (2026-01-22)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 스켈레톤 전환 범위 | **Option A: 전체 스켈레톤 전환** |
|
||||
| 구현 우선순위 | **Phase 1 먼저** (ConfirmDialog → StatusBadge → EmptyState) |
|
||||
| 확장 전략 | **옵션 기반 확장** - 새 패턴 발견 시 props 옵션으로 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 분석 요약
|
||||
|
||||
### 반복 패턴 현황
|
||||
|
||||
| 패턴 | 파일 수 | 발생 횟수 | 복잡도 | 우선순위 |
|
||||
|------|---------|----------|--------|----------|
|
||||
| 확인 다이얼로그 (삭제/저장) | 67개 | 170회 | 낮음 | 🔴 높음 |
|
||||
| 상태 스타일 매핑 | 80개 | 다수 | 낮음 | 🔴 높음 |
|
||||
| 날짜 범위 필터 | 55개 | 146회 | 중간 | 🟡 중간 |
|
||||
| 빈 상태 UI | 70개 | 86회 | 낮음 | 🟡 중간 |
|
||||
| 로딩 스피너/버튼 | 59개 | 120회 | 중간 | 🟡 중간 |
|
||||
| 스켈레톤 UI | 4개 | 92회 | 높음 | 🔴 높음 |
|
||||
|
||||
### 현재 스켈레톤 현황
|
||||
|
||||
**기존 구현:**
|
||||
- `src/components/ui/skeleton.tsx` - 기본 스켈레톤 (단순 animate-pulse div)
|
||||
- `IntegratedDetailTemplate/components/skeletons/` - 상세 페이지용 3종
|
||||
- `DetailFieldSkeleton.tsx`
|
||||
- `DetailSectionSkeleton.tsx`
|
||||
- `DetailGridSkeleton.tsx`
|
||||
- `loading.tsx` - 4개 파일만 존재 (대부분 PageLoadingSpinner 사용)
|
||||
|
||||
**문제점:**
|
||||
1. 대부분 페이지에서 로딩 스피너 사용 (스켈레톤 미적용)
|
||||
2. 리스트 페이지용 스켈레톤 없음
|
||||
3. 카드/대시보드용 스켈레톤 없음
|
||||
4. 페이지별 loading.tsx 부재 (4개만 존재)
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통화 대상 상세
|
||||
|
||||
### Phase 1: 핵심 공통 컴포넌트 (1주차)
|
||||
|
||||
#### 1-1. ConfirmDialog 컴포넌트
|
||||
|
||||
**현재 (반복 코드):**
|
||||
```tsx
|
||||
// 67개 파일에서 거의 동일하게 반복
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```tsx
|
||||
// src/components/ui/confirm-dialog.tsx
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'destructive' | 'warning';
|
||||
loading?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
title="삭제 확인"
|
||||
description="정말 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
||||
confirmText="삭제"
|
||||
variant="destructive"
|
||||
loading={isLoading}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
```
|
||||
|
||||
**효과:**
|
||||
- 코드량: ~30줄 → ~10줄 (70% 감소)
|
||||
- 일관된 UX 보장
|
||||
- 로딩 상태 자동 처리
|
||||
|
||||
---
|
||||
|
||||
#### 1-2. StatusBadge 컴포넌트 + createStatusConfig 유틸
|
||||
|
||||
**현재 (반복 코드):**
|
||||
```tsx
|
||||
// 80개 파일에서 각각 정의
|
||||
// estimates/types.ts
|
||||
export const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '대기',
|
||||
inProgress: '진행중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// site-management/types.ts (거의 동일)
|
||||
export const SITE_STATUS_STYLES: Record<string, string> = { ... };
|
||||
export const SITE_STATUS_LABELS: Record<string, string> = { ... };
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```tsx
|
||||
// src/lib/utils/status-config.ts
|
||||
export type StatusVariant = 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export interface StatusConfig<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
variant: StatusVariant;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function createStatusConfig<T extends string>(
|
||||
configs: StatusConfig<T>[]
|
||||
): {
|
||||
options: { value: T; label: string }[];
|
||||
getLabel: (status: T) => string;
|
||||
getVariant: (status: T) => StatusVariant;
|
||||
isValid: (status: string) => status is T;
|
||||
}
|
||||
|
||||
// src/components/ui/status-badge.tsx
|
||||
interface StatusBadgeProps<T extends string> {
|
||||
status: T;
|
||||
config: ReturnType<typeof createStatusConfig<T>>;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
// estimates/types.ts
|
||||
export const estimateStatusConfig = createStatusConfig([
|
||||
{ value: 'pending', label: '대기', variant: 'warning' },
|
||||
{ value: 'inProgress', label: '진행중', variant: 'info' },
|
||||
{ value: 'completed', label: '완료', variant: 'success' },
|
||||
]);
|
||||
|
||||
// 컴포넌트에서
|
||||
<StatusBadge status={data.status} config={estimateStatusConfig} />
|
||||
```
|
||||
|
||||
**효과:**
|
||||
- 타입 안전성 강화
|
||||
- 일관된 색상 체계
|
||||
- options 자동 생성 (Select용)
|
||||
|
||||
---
|
||||
|
||||
#### 1-3. EmptyState 컴포넌트
|
||||
|
||||
**현재 (반복 코드):**
|
||||
```tsx
|
||||
// 70개 파일에서 다양한 형태로 반복
|
||||
{data.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
// 또는
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-8">
|
||||
등록된 항목이 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```tsx
|
||||
// src/components/ui/empty-state.tsx
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
variant?: 'default' | 'table' | 'card' | 'minimal';
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
<EmptyState
|
||||
icon={<FileX className="w-12 h-12" />}
|
||||
title="데이터가 없습니다"
|
||||
description="새로운 항목을 등록하거나 검색 조건을 변경해보세요."
|
||||
action={<Button onClick={onCreate}>등록하기</Button>}
|
||||
/>
|
||||
|
||||
// 테이블 내 사용
|
||||
<EmptyState variant="table" colSpan={10} title="검색 결과가 없습니다" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 스켈레톤 시스템 구축 (2주차)
|
||||
|
||||
#### 2-1. 스켈레톤 컴포넌트 확장
|
||||
|
||||
**현재 문제:**
|
||||
- 기본 Skeleton만 존재 (단순 div)
|
||||
- 페이지 유형별 스켈레톤 부재
|
||||
- 대부분 PageLoadingSpinner 사용 (스켈레톤 미적용)
|
||||
|
||||
**추가할 스켈레톤:**
|
||||
|
||||
```tsx
|
||||
// src/components/ui/skeletons/
|
||||
├── index.ts // 통합 export
|
||||
├── ListPageSkeleton.tsx // 리스트 페이지용
|
||||
├── DetailPageSkeleton.tsx // 상세 페이지용 (기존 확장)
|
||||
├── CardGridSkeleton.tsx // 카드 그리드용
|
||||
├── DashboardSkeleton.tsx // 대시보드용
|
||||
├── TableSkeleton.tsx // 테이블용
|
||||
├── FormSkeleton.tsx // 폼용
|
||||
└── ChartSkeleton.tsx // 차트용
|
||||
```
|
||||
|
||||
**1. ListPageSkeleton (리스트 페이지용)**
|
||||
```tsx
|
||||
interface ListPageSkeletonProps {
|
||||
hasFilters?: boolean;
|
||||
filterCount?: number;
|
||||
hasDateRange?: boolean;
|
||||
rowCount?: number;
|
||||
columnCount?: number;
|
||||
hasActions?: boolean;
|
||||
hasPagination?: boolean;
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
export default function EstimateListLoading() {
|
||||
return (
|
||||
<ListPageSkeleton
|
||||
hasFilters
|
||||
filterCount={4}
|
||||
hasDateRange
|
||||
rowCount={10}
|
||||
columnCount={8}
|
||||
hasActions
|
||||
hasPagination
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**2. CardGridSkeleton (카드 그리드용)**
|
||||
```tsx
|
||||
interface CardGridSkeletonProps {
|
||||
cardCount?: number;
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
cardHeight?: 'sm' | 'md' | 'lg';
|
||||
hasImage?: boolean;
|
||||
hasFooter?: boolean;
|
||||
}
|
||||
|
||||
// 대시보드 카드, 칸반 보드 등에 사용
|
||||
<CardGridSkeleton cardCount={6} cols={3} cardHeight="md" />
|
||||
```
|
||||
|
||||
**3. TableSkeleton (테이블용)**
|
||||
```tsx
|
||||
interface TableSkeletonProps {
|
||||
rowCount?: number;
|
||||
columnCount?: number;
|
||||
hasCheckbox?: boolean;
|
||||
hasActions?: boolean;
|
||||
columnWidths?: string[]; // ['w-12', 'w-32', 'flex-1', ...]
|
||||
}
|
||||
|
||||
<TableSkeleton rowCount={10} columnCount={8} hasCheckbox hasActions />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2-2. loading.tsx 파일 생성 전략
|
||||
|
||||
**현재:** 4개 파일만 존재
|
||||
**목표:** 주요 페이지 경로에 맞춤형 loading.tsx 생성
|
||||
|
||||
**생성 대상 (우선순위):**
|
||||
|
||||
| 경로 | 스켈레톤 타입 | 우선순위 |
|
||||
|------|-------------|----------|
|
||||
| `/construction/project/bidding/estimates` | ListPageSkeleton | 🔴 |
|
||||
| `/construction/project/bidding` | ListPageSkeleton | 🔴 |
|
||||
| `/construction/project/contract` | ListPageSkeleton | 🔴 |
|
||||
| `/construction/order/*` | ListPageSkeleton | 🔴 |
|
||||
| `/accounting/*` | ListPageSkeleton | 🟡 |
|
||||
| `/hr/*` | ListPageSkeleton | 🟡 |
|
||||
| `/settings/*` | ListPageSkeleton | 🟢 |
|
||||
| `상세 페이지` | DetailPageSkeleton | 🟡 |
|
||||
| `대시보드` | DashboardSkeleton | 🟡 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 날짜 범위 필터 + 로딩 버튼 (3주차)
|
||||
|
||||
#### 3-1. DateRangeFilter 컴포넌트
|
||||
|
||||
**현재 (반복 코드):**
|
||||
```tsx
|
||||
// 55개 파일에서 반복
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" value={startDate} onChange={...} />
|
||||
<span>~</span>
|
||||
<Input type="date" value={endDate} onChange={...} />
|
||||
</div>
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```tsx
|
||||
// src/components/ui/date-range-filter.tsx
|
||||
interface DateRangeFilterProps {
|
||||
value: { start: string; end: string };
|
||||
onChange: (range: { start: string; end: string }) => void;
|
||||
presets?: ('today' | 'week' | 'month' | 'quarter' | 'year')[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
<DateRangeFilter
|
||||
value={{ start: startDate, end: endDate }}
|
||||
onChange={({ start, end }) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
}}
|
||||
presets={['today', 'week', 'month']}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3-2. LoadingButton 컴포넌트
|
||||
|
||||
**현재 (반복 코드):**
|
||||
```tsx
|
||||
// 59개 파일에서 반복
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
```
|
||||
|
||||
**개선안:**
|
||||
```tsx
|
||||
// src/components/ui/loading-button.tsx
|
||||
interface LoadingButtonProps extends ButtonProps {
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
spinnerPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
<LoadingButton loading={isLoading} loadingText="저장 중...">
|
||||
저장
|
||||
</LoadingButton>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 로딩 스피너 vs 스켈레톤 전략
|
||||
|
||||
### 논의 사항
|
||||
|
||||
**Option A: 전체 스켈레톤 전환**
|
||||
- 장점: 더 나은 UX, 레이아웃 시프트 방지
|
||||
- 단점: 구현 비용 높음, 페이지별 커스텀 필요
|
||||
|
||||
**Option B: 하이브리드 (권장)**
|
||||
- 페이지 로딩: 스켈레톤 (loading.tsx)
|
||||
- 버튼/액션 로딩: 스피너 유지 (LoadingButton)
|
||||
- 데이터 갱신: 스피너 유지
|
||||
|
||||
**Option C: 현행 유지**
|
||||
- 대부분 스피너 유지
|
||||
- 특정 페이지만 스켈레톤
|
||||
|
||||
### 권장안: Option B (하이브리드)
|
||||
|
||||
| 상황 | 로딩 UI | 이유 |
|
||||
|------|---------|------|
|
||||
| 페이지 초기 로딩 | 스켈레톤 | 레이아웃 힌트 제공 |
|
||||
| 페이지 전환 | 스켈레톤 | Next.js loading.tsx 활용 |
|
||||
| 버튼 클릭 (저장/삭제) | 스피너 | 짧은 작업, 버튼 내 피드백 |
|
||||
| 데이터 갱신 (필터 변경) | 스피너 or 스켈레톤 | 상황에 따라 |
|
||||
| 무한 스크롤 | 스켈레톤 | 추가 컨텐츠 힌트 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 로드맵
|
||||
|
||||
### Week 1: 핵심 컴포넌트
|
||||
- [x] ConfirmDialog 컴포넌트 생성 ✅ (2026-01-22)
|
||||
- `src/components/ui/confirm-dialog.tsx`
|
||||
- variants: default, destructive, warning, success
|
||||
- presets: DeleteConfirmDialog, SaveConfirmDialog, CancelConfirmDialog
|
||||
- 내부/외부 로딩 상태 자동 관리
|
||||
- [x] StatusBadge + createStatusConfig 유틸 생성 ✅ (2026-01-22)
|
||||
- `src/lib/utils/status-config.ts`
|
||||
- `src/components/ui/status-badge.tsx`
|
||||
- 프리셋: default, success, warning, destructive, info, muted, orange, purple
|
||||
- 모드: badge (배경+텍스트), text (텍스트만)
|
||||
- OPTIONS, LABELS, STYLES 자동 생성
|
||||
- [x] EmptyState 컴포넌트 생성 ✅ (2026-01-22)
|
||||
- `src/components/ui/empty-state.tsx`
|
||||
- variants: default, compact, large
|
||||
- presets: noData, noResults, noItems, error
|
||||
- TableEmptyState 추가 (테이블용)
|
||||
- [x] 기존 코드 마이그레이션 (10개 파일 시범) ✅ (2026-01-22)
|
||||
- PricingDetailClient.tsx - 삭제 확인
|
||||
- ItemManagementClient.tsx - 단일/일괄 삭제
|
||||
- LaborDetailClient.tsx - 삭제 확인
|
||||
- ConstructionDetailClient.tsx - 완료 확인 (warning)
|
||||
- QuoteManagementClient.tsx - 단일/일괄 삭제
|
||||
- OrderDialogs.tsx - 저장/삭제/카테고리삭제
|
||||
- DepartmentManagement/index.tsx - 삭제 확인
|
||||
- VacationManagement/index.tsx - 승인/거절 확인
|
||||
- AccountDetail.tsx - 삭제 확인
|
||||
- ProcessListClient.tsx - 삭제 확인
|
||||
|
||||
### Week 2: 스켈레톤 시스템
|
||||
- [ ] ListPageSkeleton 컴포넌트 생성
|
||||
- [ ] TableSkeleton 컴포넌트 생성
|
||||
- [ ] CardGridSkeleton 컴포넌트 생성
|
||||
- [ ] 주요 경로 loading.tsx 생성 (construction/*)
|
||||
|
||||
### Week 3: 필터 + 버튼 + 마이그레이션
|
||||
- [ ] DateRangeFilter 컴포넌트 생성
|
||||
- [ ] LoadingButton 컴포넌트 생성
|
||||
- [ ] 전체 코드 마이그레이션
|
||||
|
||||
### Week 4: 마무리 + QA
|
||||
- [ ] 남은 마이그레이션
|
||||
- [ ] 문서화
|
||||
- [ ] 성능 테스트
|
||||
|
||||
---
|
||||
|
||||
## 5. 예상 효과
|
||||
|
||||
### 코드량 감소
|
||||
| 컴포넌트 | Before | After | 감소율 |
|
||||
|---------|--------|-------|--------|
|
||||
| ConfirmDialog | ~30줄 | ~10줄 | 67% |
|
||||
| StatusBadge | ~20줄 | ~5줄 | 75% |
|
||||
| EmptyState | ~10줄 | ~3줄 | 70% |
|
||||
| DateRangeFilter | ~15줄 | ~5줄 | 67% |
|
||||
|
||||
### 일관성 향상
|
||||
- 동일한 UX 패턴 적용
|
||||
- 디자인 시스템 강화
|
||||
- 유지보수 용이성 증가
|
||||
|
||||
### 성능 개선
|
||||
- 스켈레톤으로 인지 성능 향상
|
||||
- 레이아웃 시프트 감소
|
||||
- 사용자 이탈률 감소
|
||||
|
||||
---
|
||||
|
||||
## 6. 결정 필요 사항
|
||||
|
||||
### Q1: 스켈레톤 전환 범위
|
||||
- [ ] Option A: 전체 스켈레톤 전환
|
||||
- [ ] Option B: 하이브리드 (권장)
|
||||
- [ ] Option C: 현행 유지
|
||||
|
||||
### Q2: 구현 우선순위
|
||||
- [ ] Phase 1 먼저 (ConfirmDialog, StatusBadge, EmptyState)
|
||||
- [ ] Phase 2 먼저 (스켈레톤 시스템)
|
||||
- [ ] 동시 진행
|
||||
|
||||
### Q3: 마이그레이션 범위
|
||||
- [ ] 전체 파일 한번에
|
||||
- [ ] 점진적 (신규/수정 파일만)
|
||||
- [ ] 도메인별 순차 (construction → accounting → hr)
|
||||
|
||||
---
|
||||
|
||||
## 7. 파일 구조 (최종)
|
||||
|
||||
```
|
||||
src/components/ui/
|
||||
├── confirm-dialog.tsx # Phase 1
|
||||
├── status-badge.tsx # Phase 1
|
||||
├── empty-state.tsx # Phase 1
|
||||
├── date-range-filter.tsx # Phase 3
|
||||
├── loading-button.tsx # Phase 3
|
||||
├── skeleton.tsx # 기존
|
||||
└── skeletons/ # Phase 2
|
||||
├── index.ts
|
||||
├── ListPageSkeleton.tsx
|
||||
├── DetailPageSkeleton.tsx
|
||||
├── CardGridSkeleton.tsx
|
||||
├── DashboardSkeleton.tsx
|
||||
├── TableSkeleton.tsx
|
||||
├── FormSkeleton.tsx
|
||||
└── ChartSkeleton.tsx
|
||||
|
||||
src/lib/utils/
|
||||
└── status-config.ts # Phase 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**다음 단계**: 위 결정 사항에 대한 의견 확정 후 구현 시작
|
||||
Reference in New Issue
Block a user