Compare commits
25 Commits
feature/un
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e3a0ed60 | ||
|
|
6f457b28f3 | ||
|
|
36322a0927 | ||
|
|
1d7b028693 | ||
|
|
1a6cde2d36 | ||
|
|
d2f5f3d0b5 | ||
|
|
b59150551e | ||
|
|
736c29a007 | ||
| abc3fea293 | |||
| 98b65a6ca4 | |||
|
|
4d249895ed | ||
| 34deb61632 | |||
|
|
134dec8f9d | ||
|
|
60505f52ea | ||
| b14ea842f8 | |||
| d863cccd9f | |||
| f529aea087 | |||
| 07828b63f2 | |||
| e998cfa2f8 | |||
| dc0ab88fb9 | |||
| 2d1444b956 | |||
| f6c8610104 | |||
| 0f8f40fc7b | |||
| 6dc91daaca | |||
| ebc7320eeb |
BIN
.serena/.DS_Store
vendored
Normal file
BIN
.serena/.DS_Store
vendored
Normal file
Binary file not shown.
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
81
.serena/memories/quote-registration-formfield-fix.md
Normal file
81
.serena/memories/quote-registration-formfield-fix.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 견적 등록/수정 FormField type="custom" 수정 작업
|
||||
|
||||
## 📅 작업일: 2026-01-06
|
||||
|
||||
## 🎯 문제 요약
|
||||
견적 등록/수정 페이지에서 **수량(quantity) 변경이 총합계에 반영되지 않는 버그**
|
||||
|
||||
### 증상
|
||||
- 수량 1 → 자동견적산출 → 합계 1,711,225원
|
||||
- 수량 3으로 변경 → 자동견적산출 → 합계가 여전히 1,711,225원 (3배인 ~5,133,675원이어야 함)
|
||||
|
||||
## 🔍 근본 원인
|
||||
**FormField 컴포넌트의 `type="custom"` 누락**
|
||||
|
||||
FormField 컴포넌트 (`src/components/molecules/FormField.tsx`)는 `type` prop에 따라 다르게 동작:
|
||||
- `type="custom"` → children(자식 요소)을 렌더링
|
||||
- 그 외 → 자체 내부 Input을 렌더링 (value/onChange 연결 안됨)
|
||||
|
||||
```tsx
|
||||
// FormField.tsx 내부 renderInput() 함수
|
||||
case 'custom':
|
||||
return children; // ← children 렌더링
|
||||
default:
|
||||
return <Input value={value} onChange={...} /> // ← 자체 Input 렌더링 (value=undefined)
|
||||
```
|
||||
|
||||
**결과**: `type="custom"` 없이 FormField 안에 Input을 넣으면, 해당 Input은 렌더링되지 않고 FormField 자체 Input이 렌더링됨 → state와 연결 끊김
|
||||
|
||||
## ✅ 수정 완료 (8개 FormField)
|
||||
|
||||
### 파일: `src/components/quotes/QuoteRegistration.tsx`
|
||||
|
||||
**기본정보 섹션** (3개):
|
||||
1. 등록일 (line 581) - `type="custom"` 추가
|
||||
2. 현장명 (line 627) - `type="custom"` 추가 (datalist 자동완성 포함)
|
||||
3. 납기일 (line 662) - `type="custom"` 추가
|
||||
|
||||
**견적 항목 섹션** (5개):
|
||||
4. 층수 (line 733) - `type="custom"` 추가
|
||||
5. 부호 (line 744) - `type="custom"` 추가
|
||||
6. **수량 (line 926)** - `type="custom"` 추가 ⭐ 핵심 버그 원인
|
||||
7. 마구리 날개치수 (line 944) - `type="custom"` 추가
|
||||
8. 검사비 (line 959) - `type="custom"` 추가
|
||||
|
||||
## 추가 수정사항
|
||||
|
||||
### 1. useMemo로 calculatedGrandTotal 추가 (line 194-201)
|
||||
```tsx
|
||||
const calculatedGrandTotal = useMemo(() => {
|
||||
if (!calculationResults?.items) return 0;
|
||||
return calculationResults.items.reduce((sum, itemResult) => {
|
||||
const formItem = formData.items[itemResult.index];
|
||||
return sum + (itemResult.result.grand_total * (formItem?.quantity || 1));
|
||||
}, 0);
|
||||
}, [calculationResults, formData.items]);
|
||||
```
|
||||
|
||||
### 2. Toast 메시지 수정 (line 493-498)
|
||||
`updatedItems` 사용하여 최신 상태 반영
|
||||
|
||||
### 3. Badge 및 하단 총합계
|
||||
`calculatedGrandTotal` 사용 (line 1019, 1131)
|
||||
|
||||
## ⚠️ 남은 이슈
|
||||
사용자가 "견적 산출 결과에는 왜 반영이 안되는거지?"라고 질문 → 확인 필요:
|
||||
1. 수정된 코드로 테스트했는지 (브라우저 새로고침)
|
||||
2. 수량 변경 후 즉시 반영되는지 vs 버튼 클릭 필요한지
|
||||
3. 현재 코드에서 수량 변경 시 합계는 실시간 업데이트되어야 함 (useMemo + React 재렌더링)
|
||||
|
||||
## 📁 관련 파일
|
||||
- `src/components/quotes/QuoteRegistration.tsx` - 메인 수정 파일
|
||||
- `src/components/molecules/FormField.tsx` - FormField 컴포넌트 (참조)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx` - 수정 페이지
|
||||
|
||||
## 🔑 핵심 교훈
|
||||
**FormField에 커스텀 children(Input, Select, datalist 등)을 넣을 때는 반드시 `type="custom"` 필요!**
|
||||
|
||||
## 🚀 새 세션에서 이어서 작업하려면
|
||||
1. 프로젝트 활성화: Serena `activate_project` → "react"
|
||||
2. 메모리 읽기: `read_memory("quote-registration-formfield-fix.md")`
|
||||
3. 확인 필요: 수량 변경 시 견적 산출 결과가 실시간으로 업데이트되는지 테스트
|
||||
70
.serena/memories/receivables-dynamic-months-fix.md
Normal file
70
.serena/memories/receivables-dynamic-months-fix.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
|
||||
|
||||
## 작업 일시
|
||||
2026-01-02
|
||||
|
||||
## 문제 상황
|
||||
"최근 1년" 필터가 제대로 동작하지 않는 3가지 버그:
|
||||
1. 2026년 조회 후 "최근 1년" 선택 시 2026년 기준 데이터 표시
|
||||
2. 2025년 조회 후 "최근 1년" 선택 시 2025년 기준 데이터 표시
|
||||
3. 초기 페이지 로드 시 "최근 1년" 기본값인데 데이터 없음
|
||||
|
||||
## 원인 분석
|
||||
|
||||
### 프론트엔드 (이전 세션에서 수정됨)
|
||||
- JavaScript에서 `year === 0` 체크가 falsy 값 문제로 제대로 동작하지 않음
|
||||
- `if (year)` 같은 조건문에서 0이 false로 처리됨
|
||||
|
||||
### 백엔드 (이번 세션에서 수정)
|
||||
- Laravel의 `'nullable|boolean'` 검증이 쿼리 파라미터로 전달된 문자열 "true"를 거부
|
||||
- HTTP 쿼리 파라미터는 항상 문자열로 전달됨
|
||||
|
||||
## 수정 내용
|
||||
|
||||
### 1. ReceivablesController.php
|
||||
```php
|
||||
// 변경 전
|
||||
'recent_year' => 'nullable|boolean',
|
||||
|
||||
// 변경 후
|
||||
'recent_year' => 'nullable|string|in:true,false,1,0',
|
||||
|
||||
// 검증 후 boolean 변환
|
||||
if (isset($params['recent_year'])) {
|
||||
$params['recent_year'] = filter_var($params['recent_year'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
\Log::info('[Receivables] index params', $params);
|
||||
```
|
||||
|
||||
### 2. actions.ts (이전 세션 수정, 검증됨)
|
||||
```typescript
|
||||
const yearValue = params?.year;
|
||||
if (typeof yearValue === 'number') {
|
||||
if (yearValue === 0) {
|
||||
searchParams.set('recent_year', 'true');
|
||||
} else {
|
||||
searchParams.set('year', String(yearValue));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 핵심 포인트
|
||||
1. **명시적 타입 체크**: `typeof yearValue === 'number'`로 undefined와 0을 구분
|
||||
2. **문자열 boolean 검증**: Laravel에서 `'in:true,false,1,0'` 사용 후 `filter_var()` 변환
|
||||
3. **디버그 로깅**: 개발 중 파라미터 확인을 위한 로그 추가 (테스트 후 제거 필요)
|
||||
|
||||
## Git 커밋
|
||||
- API: `4fa38e3` - feat(API): 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
|
||||
- React: `672b1b4` - feat(WEB): 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
|
||||
- React: `1f32b04` - docs: 채권현황 동적월 지원 작업 현황 업데이트
|
||||
|
||||
## 관련 파일
|
||||
- `/api/app/Http/Controllers/Api/V1/ReceivablesController.php`
|
||||
- `/api/app/Services/ReceivablesService.php`
|
||||
- `/react/src/components/accounting/ReceivablesStatus/actions.ts`
|
||||
- `/react/src/components/accounting/ReceivablesStatus/index.tsx`
|
||||
|
||||
## 후속 작업
|
||||
- [ ] 테스트 완료 후 디버그 로그 제거
|
||||
- [ ] 추가 UI 개선 (사용자 확인 필요)
|
||||
84
.serena/project.yml
Normal file
84
.serena/project.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran go
|
||||
# haskell java julia kotlin lua markdown
|
||||
# nix perl php python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala swift
|
||||
# terraform typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "react"
|
||||
included_optional_tools: []
|
||||
BIN
claudedocs/.DS_Store
vendored
BIN
claudedocs/.DS_Store
vendored
Binary file not shown.
213
claudedocs/[ANALYSIS-2026-01-20] 공통화-현황-분석.md
Normal file
213
claudedocs/[ANALYSIS-2026-01-20] 공통화-현황-분석.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 프로젝트 공통화 현황 분석
|
||||
|
||||
## 1. 핵심 지표 요약
|
||||
|
||||
| 구분 | 적용 현황 | 비고 |
|
||||
|------|----------|------|
|
||||
| **IntegratedDetailTemplate** | 96개 파일 (228회 사용) | 상세/수정/등록 페이지 통합 |
|
||||
| **IntegratedListTemplateV2** | 50개 파일 (60회 사용) | 목록 페이지 통합 |
|
||||
| **DetailConfig 파일** | 39개 생성 | 설정 기반 페이지 구성 |
|
||||
| **레거시 패턴 (PageLayout 직접 사용)** | ~40-50개 파일 | 마이그레이션 대상 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통화 달성률
|
||||
|
||||
### 2.1 상세 페이지 (Detail)
|
||||
```
|
||||
총 Detail 컴포넌트: ~105개
|
||||
IntegratedDetailTemplate 적용: ~65개
|
||||
적용률: 약 62%
|
||||
```
|
||||
|
||||
### 2.2 목록 페이지 (List)
|
||||
```
|
||||
총 List 컴포넌트: ~61개
|
||||
IntegratedListTemplateV2 적용: ~50개
|
||||
적용률: 약 82%
|
||||
```
|
||||
|
||||
### 2.3 폼 컴포넌트 (Form)
|
||||
```
|
||||
총 Form 컴포넌트: ~72개
|
||||
공통 템플릿 미적용 (개별 구현)
|
||||
적용률: 0%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 잘 공통화된 영역 ✅
|
||||
|
||||
### 3.1 템플릿 시스템
|
||||
| 템플릿 | 용도 | 적용 현황 |
|
||||
|--------|------|----------|
|
||||
| IntegratedDetailTemplate | 상세/수정/등록 | 96개 파일 |
|
||||
| IntegratedListTemplateV2 | 목록 페이지 | 50개 파일 |
|
||||
| UniversalListPage | 범용 목록 | 7개 파일 |
|
||||
|
||||
### 3.2 UI 컴포넌트 (Radix UI 기반)
|
||||
- **AlertDialog**: 65개 파일에서 일관되게 사용
|
||||
- **Dialog**: 142개 파일에서 사용
|
||||
- **Toast (Sonner)**: 133개 파일에서 일관되게 사용
|
||||
- **Pagination**: 54개 파일에서 통합 사용
|
||||
|
||||
### 3.3 데이터 테이블
|
||||
- **DataTable**: 공통 컴포넌트로 추상화됨
|
||||
- **IntegratedListTemplateV2에 통합**: 자동 페이지네이션, 필터링
|
||||
|
||||
---
|
||||
|
||||
## 4. 추가 공통화 기회 🔧
|
||||
|
||||
### 4.1 우선순위 높음 (High Priority)
|
||||
|
||||
#### 📋 Form 템플릿 (IntegratedFormTemplate)
|
||||
**현황**: 72개 Form 컴포넌트가 개별적으로 구현됨
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: IntegratedFormTemplate
|
||||
<IntegratedFormTemplate
|
||||
config={formConfig}
|
||||
mode="create" | "edit"
|
||||
initialData={data}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
renderFields={() => <CustomFields />}
|
||||
/>
|
||||
```
|
||||
**효과**:
|
||||
- 폼 레이아웃 일관성
|
||||
- 버튼 영역 통합 (저장/취소/삭제)
|
||||
- 유효성 검사 패턴 통합
|
||||
|
||||
#### 📝 레거시 페이지 마이그레이션
|
||||
**현황**: ~40-50개 파일이 PageLayout/PageHeader 직접 사용
|
||||
**대상 파일** (샘플):
|
||||
- `SubscriptionClient.tsx`
|
||||
- `SubscriptionManagement.tsx`
|
||||
- `ComprehensiveAnalysis/index.tsx`
|
||||
- `DailyReport/index.tsx`
|
||||
- `ReceivablesStatus/index.tsx`
|
||||
- `FAQManagement/FAQList.tsx`
|
||||
- `DepartmentManagement/index.tsx`
|
||||
- 등등
|
||||
|
||||
---
|
||||
|
||||
### 4.2 우선순위 중간 (Medium Priority)
|
||||
|
||||
#### 🗑️ 삭제 확인 다이얼로그 통합
|
||||
**현황**: 각 컴포넌트에서 AlertDialog 반복 구현
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: useDeleteConfirm hook
|
||||
const { openDeleteConfirm, DeleteConfirmDialog } = useDeleteConfirm({
|
||||
title: '삭제 확인',
|
||||
description: '정말 삭제하시겠습니까?',
|
||||
onConfirm: handleDelete,
|
||||
});
|
||||
|
||||
// 또는 공통 컴포넌트
|
||||
<DeleteConfirmDialog
|
||||
isOpen={isOpen}
|
||||
itemName={itemName}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 📁 파일 업로드/다운로드 패턴 통합
|
||||
**현황**: 여러 컴포넌트에서 파일 처리 로직 중복
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: useFileUpload hook
|
||||
const { uploadFile, downloadFile, FileDropzone } = useFileUpload({
|
||||
accept: ['image/*', '.pdf'],
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
});
|
||||
```
|
||||
|
||||
#### 🔄 로딩 상태 표시 통합
|
||||
**현황**: 43개 파일에서 다양한 로딩 패턴 사용
|
||||
**제안**:
|
||||
- `LoadingOverlay` 컴포넌트 확대 적용
|
||||
- `Skeleton` 패턴 표준화
|
||||
|
||||
---
|
||||
|
||||
### 4.3 우선순위 낮음 (Low Priority)
|
||||
|
||||
#### 📊 대시보드 카드 컴포넌트
|
||||
**현황**: CEO 대시보드, 생산 대시보드 등에서 유사 패턴
|
||||
**제안**: `DashboardCard`, `StatCard` 공통 컴포넌트
|
||||
|
||||
#### 🔍 검색/필터 패턴
|
||||
**현황**: IntegratedListTemplateV2에 이미 통합됨
|
||||
**추가**: 독립 검색 컴포넌트 표준화
|
||||
|
||||
---
|
||||
|
||||
## 5. 레거시 파일 정리 대상
|
||||
|
||||
### 5.1 _legacy 폴더 (삭제 검토)
|
||||
```
|
||||
src/components/hr/CardManagement/_legacy/
|
||||
- CardDetail.tsx
|
||||
- CardForm.tsx
|
||||
|
||||
src/components/settings/AccountManagement/_legacy/
|
||||
- AccountDetail.tsx
|
||||
```
|
||||
|
||||
### 5.2 V1/V2 중복 파일 (통합 검토)
|
||||
- `LaborDetailClient.tsx` vs `LaborDetailClientV2.tsx`
|
||||
- `PricingDetailClient.tsx` vs `PricingDetailClientV2.tsx`
|
||||
- `DepositDetail.tsx` vs `DepositDetailClientV2.tsx`
|
||||
- `WithdrawalDetail.tsx` vs `WithdrawalDetailClientV2.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 6. 권장 액션 플랜
|
||||
|
||||
### Phase 7: 레거시 페이지 마이그레이션
|
||||
| 순서 | 대상 | 예상 작업량 |
|
||||
|------|------|------------|
|
||||
| 1 | 설정 관리 페이지 (8개) | 중간 |
|
||||
| 2 | 회계 관리 페이지 (5개) | 중간 |
|
||||
| 3 | 인사 관리 페이지 (5개) | 중간 |
|
||||
| 4 | 보고서/분석 페이지 (3개) | 낮음 |
|
||||
|
||||
### Phase 8: Form 템플릿 개발
|
||||
1. IntegratedFormTemplate 설계
|
||||
2. 파일럿 적용 (2-3개 Form)
|
||||
3. 점진적 마이그레이션
|
||||
|
||||
### Phase 9: 유틸리티 Hook 개발
|
||||
1. useDeleteConfirm
|
||||
2. useFileUpload
|
||||
3. useFormState (공통 폼 상태 관리)
|
||||
|
||||
### Phase 10: 레거시 정리
|
||||
1. _legacy 폴더 삭제
|
||||
2. V1/V2 중복 파일 통합
|
||||
3. 미사용 컴포넌트 정리
|
||||
|
||||
---
|
||||
|
||||
## 7. 결론
|
||||
|
||||
### 공통화 성과
|
||||
- **상세 페이지**: 62% 공통화 달성 (Phase 6 완료)
|
||||
- **목록 페이지**: 82% 공통화 달성
|
||||
- **UI 컴포넌트**: Radix UI 기반 일관성 확보
|
||||
- **토스트/알림**: Sonner로 완전 통합
|
||||
|
||||
### 남은 과제
|
||||
- **Form 템플릿**: 72개 폼 컴포넌트 공통화 필요
|
||||
- **레거시 페이지**: ~40-50개 마이그레이션 필요
|
||||
- **코드 정리**: _legacy, V1/V2 중복 파일 정리
|
||||
|
||||
### 예상 효과 (추가 공통화 시)
|
||||
- 코드 중복 30% 추가 감소
|
||||
- 신규 페이지 개발 시간 50% 단축
|
||||
- 유지보수성 대폭 향상
|
||||
158
claudedocs/[ANALYSIS] common-component-patterns.md
Normal file
158
claudedocs/[ANALYSIS] common-component-patterns.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 공통 컴포넌트 패턴 분석
|
||||
|
||||
> Phase 3 마이그레이션 진행하면서 발견되는 공통화 후보 패턴 수집
|
||||
> 페이지 마이그레이션 완료 후 이 리스트 기반으로 공통 컴포넌트 설계
|
||||
|
||||
## 최종 목표: IntegratedDetailTemplate Config 통합
|
||||
|
||||
공통 컴포넌트 추출 후 `IntegratedDetailTemplate`의 필드 config 옵션으로 통합
|
||||
|
||||
**현재 지원 타입:**
|
||||
```typescript
|
||||
type: 'text' | 'select' | 'date' | 'textarea' | 'number' | 'checkbox'
|
||||
```
|
||||
|
||||
**확장 예정 타입:**
|
||||
```typescript
|
||||
// 주소 입력
|
||||
{ name: 'address', label: '주소', type: 'address', withCoords?: boolean }
|
||||
|
||||
// 파일 업로드
|
||||
{ name: 'files', label: '첨부파일', type: 'file-upload', maxSize?: number, multiple?: boolean, accept?: string }
|
||||
|
||||
// 음성 메모
|
||||
{ name: 'memo', label: '메모', type: 'voice-memo' }
|
||||
|
||||
// 삭제 확인 (페이지 레벨 옵션)
|
||||
deleteConfirm?: { title: string, message: string }
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 페이지별 config만 수정하면 UI 자동 구성
|
||||
- 일관된 UX/UI 보장
|
||||
- 유지보수 용이
|
||||
|
||||
## 발견된 패턴 목록
|
||||
|
||||
### 1. 주소 입력 (Address Input)
|
||||
| 발견 위치 | 구성 요소 | 특이사항 |
|
||||
|-----------|-----------|----------|
|
||||
| site-management/SiteDetailForm | 우편번호 찾기 버튼 + 주소 Input + 상세주소 | 경도/위도 필드 별도 |
|
||||
|
||||
**공통 요소:**
|
||||
- Daum Postcode API 연동 (`useDaumPostcode` 훅 이미 존재)
|
||||
- 우편번호 찾기 버튼
|
||||
- 기본주소 (자동 입력)
|
||||
- 상세주소 (수동 입력)
|
||||
|
||||
**변형 가능성:**
|
||||
- 경도/위도 필드 포함 여부
|
||||
- 읽기 전용 모드 지원
|
||||
|
||||
---
|
||||
|
||||
### 2. 파일 업로드 (File Upload)
|
||||
| 발견 위치 | 구성 요소 | 특이사항 |
|
||||
|-----------|-----------|----------|
|
||||
| site-management/SiteDetailForm | 드래그앤드롭 영역 + 파일 목록 | 10MB 제한, 다중 파일 |
|
||||
| structure-review/StructureReviewDetailForm | 드래그앤드롭 영역 + 파일 목록 | 10MB 제한, 다중 파일 (동일 패턴) |
|
||||
|
||||
**공통 요소:**
|
||||
- 드래그앤드롭 영역 (점선 박스)
|
||||
- 클릭하여 파일 선택
|
||||
- 드래그 중 시각적 피드백
|
||||
- 파일 크기 검증
|
||||
|
||||
**변형 가능성:**
|
||||
- 허용 파일 타입 (이미지만 / 문서만 / 전체)
|
||||
- 단일 vs 다중 파일
|
||||
- 최대 파일 크기
|
||||
- 최대 파일 개수
|
||||
|
||||
---
|
||||
|
||||
### 3. 파일 목록 (File List)
|
||||
| 발견 위치 | 구성 요소 | 특이사항 |
|
||||
|-----------|-----------|----------|
|
||||
| site-management/SiteDetailForm | 파일명 + 크기 + 다운로드/삭제 | view/edit 모드별 다른 액션 |
|
||||
| structure-review/StructureReviewDetailForm | 파일명 + 크기 + 다운로드/삭제 | 동일 패턴 |
|
||||
|
||||
**공통 요소:**
|
||||
- 파일 아이콘
|
||||
- 파일명 표시
|
||||
- 파일 크기 표시
|
||||
- 액션 버튼
|
||||
|
||||
**변형 가능성:**
|
||||
- view 모드: 다운로드 버튼
|
||||
- edit 모드: 삭제(X) 버튼
|
||||
- 미리보기 지원 (이미지)
|
||||
- 업로드 날짜 표시 여부
|
||||
|
||||
---
|
||||
|
||||
### 4. 음성 녹음 (Voice Recorder)
|
||||
| 발견 위치 | 구성 요소 | 특이사항 |
|
||||
|-----------|-----------|----------|
|
||||
| site-management/SiteDetailForm | 녹음 버튼 (Textarea 내부) | edit 모드에서만 표시 |
|
||||
|
||||
**공통 요소:**
|
||||
- 녹음 시작/중지 버튼
|
||||
- Textarea와 연동 (STT 결과 입력)
|
||||
|
||||
**변형 가능성:**
|
||||
- 버튼 위치 (Textarea 내부 / 외부)
|
||||
- 녹음 시간 제한
|
||||
- 녹음 중 시각적 피드백
|
||||
|
||||
---
|
||||
|
||||
### 5. 삭제 확인 다이얼로그 (Delete Confirmation Dialog)
|
||||
| 발견 위치 | 구성 요소 | 특이사항 |
|
||||
|-----------|-----------|----------|
|
||||
| structure-review/StructureReviewDetailForm | AlertDialog + 제목 + 설명 + 취소/삭제 버튼 | view 모드에서 삭제 버튼 클릭 시 |
|
||||
|
||||
**공통 요소:**
|
||||
- AlertDialog 컴포넌트 사용
|
||||
- 제목: "[항목명] 삭제"
|
||||
- 설명: 삭제 확인 메시지 + 되돌릴 수 없음 경고
|
||||
- 취소/삭제 버튼
|
||||
|
||||
**변형 가능성:**
|
||||
- 항목명 커스터마이징
|
||||
- 삭제 후 리다이렉트 경로
|
||||
- 추가 경고 메시지
|
||||
|
||||
---
|
||||
|
||||
## 추가 예정
|
||||
|
||||
> Phase 3 마이그레이션 진행하면서 새로운 패턴 발견 시 여기에 추가
|
||||
|
||||
### 예상 패턴 (확인 필요)
|
||||
- [ ] 이미지 미리보기 (썸네일)
|
||||
- [ ] 서명 입력
|
||||
- [ ] 날짜/시간 선택
|
||||
- [ ] 검색 가능한 Select (Combobox)
|
||||
- [ ] 태그 입력
|
||||
- [ ] 금액 입력 (천단위 콤마)
|
||||
|
||||
---
|
||||
|
||||
## 공통화 우선순위 (마이그레이션 완료 후 결정)
|
||||
|
||||
| 우선순위 | 패턴 | 사용 빈도 | 복잡도 |
|
||||
|----------|------|-----------|--------|
|
||||
| - | 주소 입력 | 1 | 중 |
|
||||
| - | 파일 업로드 | 2 | 상 |
|
||||
| - | 파일 목록 | 2 | 중 |
|
||||
| - | 음성 녹음 | 1 | 상 |
|
||||
| - | 삭제 확인 다이얼로그 | 1 | 하 |
|
||||
|
||||
> 사용 빈도는 마이그레이션 진행하면서 카운트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
- 2025-01-19: 초안 작성, site-management에서 4개 패턴 발견
|
||||
- 2025-01-19: structure-review에서 동일 패턴 확인 (파일 업로드/목록), 삭제 확인 다이얼로그 패턴 추가
|
||||
@@ -0,0 +1,137 @@
|
||||
# IntegratedDetailTemplate 마이그레이션 체크리스트
|
||||
|
||||
## 목표
|
||||
- 타이틀/버튼 영역(목록, 상세, 취소, 수정) 공통화
|
||||
- 반응형 입력 필드 통합
|
||||
- 특수 기능(테이블, 모달, 문서 미리보기 등)은 renderView/renderForm으로 유지
|
||||
|
||||
## 마이그레이션 패턴
|
||||
```typescript
|
||||
// 1. config 파일 생성
|
||||
export const xxxConfig: DetailConfig = {
|
||||
title: '페이지 타이틀',
|
||||
description: '설명',
|
||||
icon: IconComponent,
|
||||
basePath: '/path/to/list',
|
||||
fields: [], // renderView/renderForm 사용 시 빈 배열
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true/false,
|
||||
showEdit: true/false,
|
||||
// ... labels
|
||||
},
|
||||
};
|
||||
|
||||
// 2. 컴포넌트에서 IntegratedDetailTemplate 사용
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={data}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit} // Promise<{ success: boolean; error?: string }>
|
||||
onDelete={handleDelete} // Promise<{ success: boolean; error?: string }>
|
||||
headerActions={customHeaderActions} // 커스텀 버튼
|
||||
renderView={() => renderContent()}
|
||||
renderForm={() => renderContent()}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 현황
|
||||
|
||||
### ✅ 완료 (Phase 6)
|
||||
|
||||
| No | 카테고리 | 컴포넌트 | 파일 | 특이사항 |
|
||||
|----|---------|---------|------|----------|
|
||||
| 1 | 건설/시공 | 협력업체 | PartnerForm.tsx | - |
|
||||
| 2 | 건설/시공 | 시공관리 | ConstructionDetailClient.tsx | - |
|
||||
| 3 | 건설/시공 | 기성관리 | ProgressBillingDetailForm.tsx | - |
|
||||
| 4 | 건설/시공 | 발주관리 | OrderDetailForm.tsx | - |
|
||||
| 5 | 건설/시공 | 계약관리 | ContractDetailForm.tsx | - |
|
||||
| 6 | 건설/시공 | 인수인계보고서 | HandoverReportDetailForm.tsx | - |
|
||||
| 7 | 건설/시공 | 견적관리 | EstimateDetailForm.tsx | - |
|
||||
| 8 | 건설/시공 | 현장브리핑 | SiteBriefingForm.tsx | - |
|
||||
| 9 | 건설/시공 | 이슈관리 | IssueDetailForm.tsx | - |
|
||||
| 10 | 건설/시공 | 입찰관리 | BiddingDetailForm.tsx | - |
|
||||
| 11 | 영업 | 견적관리(V2) | QuoteRegistrationV2.tsx | hideHeader prop, 자동견적/푸터바 유지 |
|
||||
| 12 | 영업 | 고객관리(V2) | ClientDetailClientV2.tsx | - |
|
||||
| 13 | 회계 | 청구관리 | BillDetail.tsx | - |
|
||||
| 14 | 회계 | 매입관리 | PurchaseDetail.tsx | - |
|
||||
| 15 | 회계 | 매출관리 | SalesDetail.tsx | - |
|
||||
| 16 | 회계 | 거래처관리 | VendorDetail.tsx | - |
|
||||
| 17 | 회계 | 입금관리(V2) | DepositDetailClientV2.tsx | - |
|
||||
| 18 | 회계 | 출금관리(V2) | WithdrawalDetailClientV2.tsx | - |
|
||||
| 19 | 생산 | 작업지시 | WorkOrderDetail.tsx | 상태변경버튼, 작업일지 모달 유지 |
|
||||
| 20 | 품질 | 검수관리 | InspectionDetail.tsx | 성적서 버튼 |
|
||||
| 21 | 출고 | 출하관리 | ShipmentDetail.tsx | 문서 미리보기 모달, 조건부 수정/삭제 |
|
||||
| 22 | 기준정보 | 단가관리(V2) | PricingDetailClientV2.tsx | - |
|
||||
| 23 | 기준정보 | 노무관리(V2) | LaborDetailClientV2.tsx | - |
|
||||
| 24 | 설정 | 팝업관리(V2) | PopupDetailClientV2.tsx | - |
|
||||
| 25 | 설정 | 계정관리 | accounts/[id]/page.tsx | - |
|
||||
| 26 | 설정 | 공정관리 | process-management/[id]/page.tsx | - |
|
||||
| 27 | 설정 | 게시판관리 | board-management/[id]/page.tsx | - |
|
||||
| 28 | 인사 | 명함관리 | card-management/[id]/page.tsx | - |
|
||||
| 29 | 영업 | 수주관리 | OrderSalesDetailView.tsx, OrderSalesDetailEdit.tsx | 문서 모달, 상태별 버튼, 확정/취소 다이얼로그 유지 |
|
||||
| 30 | 자재 | 입고관리 | ReceivingDetail.tsx | 입고증/입고처리/성공 다이얼로그, 상태별 버튼 |
|
||||
| 31 | 자재 | 재고현황 | StockStatusDetail.tsx | LOT별 상세 재고 테이블, FIFO 권장 메시지 |
|
||||
| 32 | 회계 | 악성채권 | BadDebtDetail.tsx | 저장 확인 다이얼로그, 파일 업로드/다운로드 |
|
||||
| 33 | 회계 | 거래처원장 | VendorLedgerDetail.tsx | 기간선택, PDF 다운로드, 판매/수금 테이블 |
|
||||
| 34 | 건설 | 구조검토 | StructureReviewDetailForm.tsx | view/edit/new 모드, 파일 드래그앤드롭 |
|
||||
| 35 | 건설 | 현장관리 | SiteDetailForm.tsx | 다음 우편번호 API, 파일 드래그앤드롭 |
|
||||
| 36 | 건설 | 품목관리 | ItemDetailClient.tsx | view/edit/new 모드, 동적 발주 항목 리스트 |
|
||||
| 37 | 고객센터 | 문의관리 | InquiryDetail.tsx | 댓글 CRUD, 작성자/상태별 버튼 표시 |
|
||||
| 38 | 고객센터 | 이벤트관리 | EventDetail.tsx | view 모드만 |
|
||||
| 39 | 고객센터 | 공지관리 | NoticeDetail.tsx | view 모드만, 이미지/첨부파일 |
|
||||
| 40 | 인사 | 직원관리 | EmployeeDetail.tsx | 기본정보/인사정보/사용자정보 카드 |
|
||||
| 41 | 설정 | 권한관리 | PermissionDetail.tsx | 인라인 수정, 메뉴별 권한 테이블, 자동 저장 |
|
||||
|
||||
---
|
||||
|
||||
## Config 파일 위치
|
||||
|
||||
| 컴포넌트 | Config 파일 |
|
||||
|---------|------------|
|
||||
| 출하관리 | shipmentConfig.ts |
|
||||
| 작업지시 | workOrderConfig.ts |
|
||||
| 검수관리 | inspectionConfig.ts |
|
||||
| 견적관리(V2) | quoteConfig.ts |
|
||||
| 수주관리 | orderSalesConfig.ts |
|
||||
| 입고관리 | receivingConfig.ts |
|
||||
| 재고현황 | stockStatusConfig.ts |
|
||||
| 악성채권 | badDebtConfig.ts |
|
||||
| 거래처원장 | vendorLedgerConfig.ts |
|
||||
| 구조검토 | structureReviewConfig.ts |
|
||||
| 현장관리 | siteConfig.ts |
|
||||
| 품목관리 | itemConfig.ts |
|
||||
| 문의관리 | inquiryConfig.ts |
|
||||
| 이벤트관리 | eventConfig.ts |
|
||||
| 공지관리 | noticeConfig.ts |
|
||||
| 직원관리 | employeeConfig.ts |
|
||||
| 권한관리 | permissionConfig.ts |
|
||||
|
||||
---
|
||||
|
||||
## 작업 로그
|
||||
|
||||
### 2026-01-20
|
||||
- Phase 6 마이그레이션 시작
|
||||
- 검수관리, 작업지시, 출하관리 완료
|
||||
- 견적관리(V2 테스트) 완료 - hideHeader 패턴 적용
|
||||
- 수주관리 완료 - OrderSalesDetailView.tsx, OrderSalesDetailEdit.tsx 마이그레이션
|
||||
- 입고관리 완료 - ReceivingDetail.tsx 마이그레이션
|
||||
- 재고현황 완료 - StockStatusDetail.tsx 마이그레이션 (LOT 테이블, FIFO 권장 메시지)
|
||||
- 악성채권 완료 - BadDebtDetail.tsx 마이그레이션 (저장 확인 다이얼로그, 파일 업로드/다운로드)
|
||||
- 거래처원장 완료 - VendorLedgerDetail.tsx 마이그레이션 (기간선택, PDF 다운로드, 판매/수금 테이블)
|
||||
- 구조검토 완료 - StructureReviewDetailForm.tsx 마이그레이션 (view/edit/new 모드, 파일 드래그앤드롭)
|
||||
- 현장관리 완료 - SiteDetailForm.tsx 마이그레이션 (다음 우편번호 API, 파일 드래그앤드롭)
|
||||
- 품목관리 완료 - ItemDetailClient.tsx 마이그레이션 (view/edit/new 모드, 동적 발주 항목 리스트)
|
||||
- 프로젝트관리 제외 - 칸반보드 형태라 IntegratedDetailTemplate 대상 아님
|
||||
- 문의관리 완료 - InquiryDetail.tsx 마이그레이션 (댓글 CRUD, 작성자/상태별 버튼 표시)
|
||||
- 이벤트관리 완료 - EventDetail.tsx 마이그레이션 (view 모드만)
|
||||
- 공지관리 완료 - NoticeDetail.tsx 마이그레이션 (view 모드만, 이미지/첨부파일)
|
||||
- 직원관리 완료 - EmployeeDetail.tsx 마이그레이션 (기본정보/인사정보/사용자정보 카드)
|
||||
- 권한관리 완료 - PermissionDetail.tsx 마이그레이션 (인라인 수정, 메뉴별 권한 테이블, 자동 저장, AlertDialog 유지)
|
||||
- **Phase 6 마이그레이션 완료** - 총 41개 컴포넌트 마이그레이션 완료
|
||||
475
claudedocs/[IMPL] integrated-detail-template-checklist.md
Normal file
475
claudedocs/[IMPL] integrated-detail-template-checklist.md
Normal file
@@ -0,0 +1,475 @@
|
||||
# V2 통합 마이그레이션 현황
|
||||
|
||||
> 브랜치: `feature/universal-detail-component`
|
||||
> 최종 수정: 2026-01-20 (v28 - 폼 템플릿 공통화 추가)
|
||||
|
||||
---
|
||||
|
||||
## 📊 전체 진행 현황
|
||||
|
||||
| 단계 | 내용 | 상태 | 대상 |
|
||||
|------|------|------|------|
|
||||
| **Phase 1-5** | V2 URL 패턴 통합 | ✅ 완료 | 37개 |
|
||||
| **Phase 6** | 폼 템플릿 공통화 | 🔄 진행중 | 37개 |
|
||||
|
||||
---
|
||||
|
||||
## 📌 V2 URL 패턴이란?
|
||||
|
||||
```
|
||||
기존: /[id] (조회) + /[id]/edit (수정) → 별도 페이지
|
||||
V2: /[id]?mode=view (조회) + /[id]?mode=edit (수정) → 단일 페이지
|
||||
```
|
||||
|
||||
**핵심**: `searchParams.get('mode')` 로 view/edit 분기
|
||||
|
||||
---
|
||||
|
||||
## 📊 최종 현황 표
|
||||
|
||||
### 통계 요약
|
||||
|
||||
| 구분 | 개수 |
|
||||
|------|------|
|
||||
| ✅ V2 완료 | 37개 |
|
||||
| ❌ 제외 (복잡 구조) | 2개 |
|
||||
| ⚪ 불필요 (View only 등) | 8개 |
|
||||
| **합계** | **47개** |
|
||||
|
||||
---
|
||||
|
||||
### 🏦 회계 (Accounting) - 8개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 입금 | `/accounting/deposits/[id]` | ✅ 완료 | Phase 5 |
|
||||
| 출금 | `/accounting/withdrawals/[id]` | ✅ 완료 | Phase 5 |
|
||||
| 거래처 | `/accounting/vendors/[id]` | ✅ 완료 | 기존 V2 |
|
||||
| 매출 | `/accounting/sales/[id]` | ✅ 완료 | 기존 V2 |
|
||||
| 매입 | `/accounting/purchase/[id]` | ✅ 완료 | 기존 V2 |
|
||||
| 세금계산서 | `/accounting/bills/[id]` | ✅ 완료 | 기존 V2 |
|
||||
| 대손추심 | `/accounting/bad-debt-collection/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 거래처원장 | `/accounting/vendor-ledger/[id]` | ⚪ 불필요 | 조회 전용 탭 |
|
||||
|
||||
---
|
||||
|
||||
### 🏗️ 건설 (Construction) - 16개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 노무관리 | `/construction/base-info/labor/[id]` | ✅ 완료 | Phase 2 |
|
||||
| 단가관리 | `/construction/base-info/pricing/[id]` | ✅ 완료 | Phase 5 |
|
||||
| 품목관리(건설) | `/construction/base-info/items/[id]` | ✅ 완료 | 기존 V2 |
|
||||
| 현장관리 | `/construction/site-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 실행내역 | `/construction/order/structure-review/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 입찰관리 | `/construction/project/bidding/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 이슈관리 | `/construction/project/issue-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 현장설명회 | `/construction/project/bidding/site-briefings/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 견적서 | `/construction/project/bidding/estimates/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 협력업체 | `/construction/partners/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 시공관리 | `/construction/construction-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 기성관리 | `/construction/billing/progress-billing-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 발주관리 | `/construction/order/order-management/[id]` | ✅ 완료 | Phase 4 |
|
||||
| 계약관리 | `/construction/project/contract/[id]` | ✅ 완료 | Phase 4 |
|
||||
| 인수인계보고서 | `/construction/project/contract/handover-report/[id]` | ✅ 완료 | Phase 4 |
|
||||
| 현장종합현황 | `/construction/project/management/[id]` | ❌ 제외 | 칸반 보드 |
|
||||
|
||||
---
|
||||
|
||||
### 💼 판매 (Sales) - 7개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 거래처(영업) | `/sales/client-management-sales-admin/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 견적관리 | `/sales/quote-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 견적(테스트) | `/sales/quote-management/test/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 판매수주관리 | `/sales/order-management-sales/[id]` | ✅ 완료 | Phase 5 |
|
||||
| 단가관리 | `/sales/pricing-management/[id]` | ✅ 완료 | Phase 4 |
|
||||
| 수주관리 | `/sales/order-management/[id]` | ⚪ 불필요 | 복잡 워크플로우 |
|
||||
| 생산의뢰 | `/sales/production-orders/[id]` | ⚪ 불필요 | 조회 전용 |
|
||||
|
||||
---
|
||||
|
||||
### 👥 인사 (HR) - 2개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 카드관리 | `/hr/card-management/[id]` | ✅ 완료 | Phase 1 |
|
||||
| 사원관리 | `/hr/employee-management/[id]` | ✅ 완료 | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
### 🏭 생산 (Production) - 2개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 작업지시 | `/production/work-orders/[id]` | ✅ 완료 | Phase 4 |
|
||||
| 스크린생산 | `/production/screen-production/[id]` | ✅ 완료 | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
### 🔍 품질 (Quality) - 1개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 검수관리 | `/quality/inspections/[id]` | ✅ 완료 | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
### 📦 출고 (Outbound) - 1개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 출하관리 | `/outbound/shipments/[id]` | ✅ 완료 | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
### 📥 자재 (Material) - 2개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 재고현황 | `/material/stock-status/[id]` | ⚪ 불필요 | LOT 테이블 조회 |
|
||||
| 입고관리 | `/material/receiving-management/[id]` | ⚪ 불필요 | 복잡 워크플로우 |
|
||||
|
||||
---
|
||||
|
||||
### 📞 고객센터 (Customer Center) - 3개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| Q&A | `/customer-center/qna/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 공지사항 | `/customer-center/notices/[id]` | ⚪ 불필요 | View only |
|
||||
| 이벤트 | `/customer-center/events/[id]` | ⚪ 불필요 | View only |
|
||||
|
||||
---
|
||||
|
||||
### 📋 게시판 (Board) - 1개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 게시판관리 | `/board/board-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ 설정 (Settings) - 3개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 계좌관리 | `/settings/accounts/[id]` | ✅ 완료 | Phase 1 |
|
||||
| 팝업관리 | `/settings/popup-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
| 권한관리 | `/settings/permissions/[id]` | ❌ 제외 | Matrix UI |
|
||||
|
||||
---
|
||||
|
||||
### 🔧 기준정보 (Master Data) - 1개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 공정관리 | `/master-data/process-management/[id]` | ✅ 완료 | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
### 📦 품목 (Items) - 1개
|
||||
|
||||
| 페이지 | 경로 | V2 상태 | 비고 |
|
||||
|--------|------|---------|------|
|
||||
| 품목관리 | `/items/[id]` | ✅ 완료 | Phase 5 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 V2 마이그레이션 패턴
|
||||
|
||||
### Pattern A: mode prop 지원
|
||||
|
||||
기존 컴포넌트가 `mode` prop을 지원하는 경우
|
||||
|
||||
```tsx
|
||||
// page.tsx
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
return <ExistingV2Component mode={mode} />;
|
||||
|
||||
// edit/page.tsx → 리다이렉트
|
||||
router.replace(`/path/${id}?mode=edit`);
|
||||
```
|
||||
|
||||
### Pattern B: View/Edit 컴포넌트 분리
|
||||
|
||||
View와 Edit가 완전히 다른 구현인 경우
|
||||
|
||||
```tsx
|
||||
// 새 컴포넌트: ComponentDetailView.tsx, ComponentDetailEdit.tsx
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
if (mode === 'edit') {
|
||||
return <ComponentDetailEdit id={id} />;
|
||||
}
|
||||
return <ComponentDetailView id={id} />;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 공통 컴포넌트 참조
|
||||
|
||||
| 컴포넌트 | 위치 | 용도 |
|
||||
|----------|------|------|
|
||||
| IntegratedDetailTemplate | `src/components/templates/IntegratedDetailTemplate/` | 상세 페이지 템플릿 |
|
||||
| ErrorCard | `src/components/ui/error-card.tsx` | 에러 UI (not-found, network) |
|
||||
| ServerErrorPage | `src/components/common/ServerErrorPage.tsx` | 서버 에러 페이지 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 변경 이력
|
||||
|
||||
<details>
|
||||
<summary>전체 변경 이력 보기 (v1 ~ v27)</summary>
|
||||
|
||||
| 날짜 | 버전 | 내용 |
|
||||
|------|------|------|
|
||||
| 2026-01-17 | v1 | 체크리스트 초기 작성 |
|
||||
| 2026-01-17 | v2 | 심층 검토 반영 |
|
||||
| 2026-01-19 | v3 | 내부 컴포넌트 공통화 통합 |
|
||||
| 2026-01-19 | v4 | 스켈레톤 컴포넌트 추가 |
|
||||
| 2026-01-19 | v5 | Chrome DevTools 동작 검증 완료 |
|
||||
| 2026-01-19 | v6 | DetailField 미적용 이슈 발견 |
|
||||
| 2026-01-19 | v7 | DetailField 미적용 이슈 해결 완료 |
|
||||
| 2026-01-19 | v8 | 📊 47개 상세 페이지 전체 분석 완료 |
|
||||
| 2026-01-19 | v9 | 📋 리스트/상세 차이 설명 추가, 🧪 기능 검수 섹션 추가 |
|
||||
| 2026-01-19 | v10 | 🔧 buttonPosition prop 추가 |
|
||||
| 2026-01-19 | v11 | 🚀 노무관리 마이그레이션 완료 |
|
||||
| 2026-01-19 | v12 | 🚀 단가관리(건설) 마이그레이션 완료 |
|
||||
| 2026-01-19 | v13 | 🚀 입금관리 마이그레이션 완료 |
|
||||
| 2026-01-19 | v14 | 📊 Phase 2 분석 및 대규모 재분류 |
|
||||
| 2026-01-19 | v15 | ✅ Phase 2 최종 완료 |
|
||||
| 2026-01-19 | v16 | 🚀 Phase 3 라우팅 구조 변경 4개 완료, 🎨 ErrorCard 추가 |
|
||||
| 2026-01-19 | v17 | 🚀 Phase 3 대손추심 완료 |
|
||||
| 2026-01-19 | v18 | 🚀 Phase 3 Q&A 완료 |
|
||||
| 2026-01-19 | v19 | 🚀 Phase 3 건설/판매 도메인 3개 추가 완료 |
|
||||
| 2026-01-19 | v20 | 🧪 견적 테스트 페이지 V2 패턴 적용 |
|
||||
| 2026-01-19 | v21 | 🚀 Phase 3 건설 도메인 4개 추가 완료 |
|
||||
| 2026-01-19 | v22 | 🚨 ServerErrorPage 필수 적용 섹션 추가 |
|
||||
| 2026-01-19 | v23 | 🚀 기성관리 V2 마이그레이션 완료 |
|
||||
| 2026-01-19 | v24 | 📊 Phase 3 최종 분석 완료 |
|
||||
| 2026-01-19 | v25 | 🚀 Phase 4 추가 (9개 페이지 식별) |
|
||||
| 2026-01-19 | v26 | 🎯 Phase 5 완료 (5개 V2 URL 패턴 통합) |
|
||||
| 2026-01-20 | v27 | 📋 문서 정리 - 최종 현황 표 중심으로 재구성 |
|
||||
| 2026-01-20 | v28 | 🎨 Phase 6 폼 템플릿 공통화 마이그레이션 추가 |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Phase 6: 폼 템플릿 공통화 마이그레이션
|
||||
|
||||
### 목표
|
||||
|
||||
모든 등록/상세/수정 페이지를 공통 템플릿 기반으로 통합하여 **한 파일 수정으로 전체 페이지 일괄 적용** 가능하게 함.
|
||||
|
||||
### 공통화 대상
|
||||
|
||||
| 항목 | 컴포넌트 | 효과 |
|
||||
|------|----------|------|
|
||||
| 페이지 레이아웃 | `ResponsiveFormTemplate` | 헤더/버튼 위치 일괄 변경 |
|
||||
| 입력 필드 그리드 | `FormFieldGrid` | PC 4열/모바일 1열 등 반응형 일괄 변경 |
|
||||
| 입력 필드 스타일 | `FormField` | 라벨/에러/스타일 일괄 변경 |
|
||||
| 하단 버튼 | `FormActions` | 저장/취소 버튼 sticky 고정 |
|
||||
|
||||
### 사용법
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
FormField
|
||||
} from '@/components/templates/ResponsiveFormTemplate';
|
||||
|
||||
export default function ExampleEditPage() {
|
||||
return (
|
||||
<ResponsiveFormTemplate
|
||||
title="품목 등록"
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
>
|
||||
<FormSection title="기본 정보">
|
||||
<FormFieldGrid columns={4}>
|
||||
<FormField label="품목코드" required value={code} onChange={setCode} />
|
||||
<FormField label="품목명" required value={name} onChange={setName} />
|
||||
<FormField label="단위" type="select" options={unitOptions} value={unit} onChange={setUnit} />
|
||||
<FormField label="상태" type="select" options={statusOptions} value={status} onChange={setStatus} />
|
||||
</FormFieldGrid>
|
||||
</FormSection>
|
||||
</ResponsiveFormTemplate>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 반응형 그리드 설정
|
||||
|
||||
```tsx
|
||||
// FormFieldGrid.tsx - 이 파일만 수정하면 전체 적용
|
||||
const gridClasses = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-1 md:grid-cols-2",
|
||||
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 체크리스트
|
||||
|
||||
#### 🏦 회계 (Accounting) - 7개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 입금 | `/accounting/deposits/[id]` | ✅ 완료 |
|
||||
| 출금 | `/accounting/withdrawals/[id]` | ✅ 완료 |
|
||||
| 거래처 | `/accounting/vendors/[id]` | ✅ 완료 |
|
||||
| 매출 | `/accounting/sales/[id]` | ✅ 완료 |
|
||||
| 매입 | `/accounting/purchase/[id]` | ✅ 완료 |
|
||||
| 세금계산서 | `/accounting/bills/[id]` | ✅ 완료 |
|
||||
| 대손추심 | `/accounting/bad-debt-collection/[id]` | 🔶 복잡 |
|
||||
|
||||
#### 🏗️ 건설 (Construction) - 15개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 노무관리 | `/construction/base-info/labor/[id]` | ✅ 완료 |
|
||||
| 단가관리 | `/construction/base-info/pricing/[id]` | ✅ 완료 |
|
||||
| 품목관리(건설) | `/construction/base-info/items/[id]` | 🔶 복잡 |
|
||||
| 현장관리 | `/construction/site-management/[id]` | 🔶 복잡 |
|
||||
| 실행내역 | `/construction/order/structure-review/[id]` | 🔶 복잡 |
|
||||
| 입찰관리 | `/construction/project/bidding/[id]` | ✅ 완료 |
|
||||
| 이슈관리 | `/construction/project/issue-management/[id]` | ✅ 완료 |
|
||||
| 현장설명회 | `/construction/project/bidding/site-briefings/[id]` | ✅ 완료 |
|
||||
| 견적서 | `/construction/project/bidding/estimates/[id]` | ✅ 완료 |
|
||||
| 협력업체 | `/construction/partners/[id]` | ✅ 완료 |
|
||||
| 시공관리 | `/construction/construction-management/[id]` | ✅ 완료 |
|
||||
| 기성관리 | `/construction/billing/progress-billing-management/[id]` | ✅ 완료 |
|
||||
| 발주관리 | `/construction/order/order-management/[id]` | ⬜ 대기 |
|
||||
| 계약관리 | `/construction/project/contract/[id]` | ⬜ 대기 |
|
||||
| 인수인계보고서 | `/construction/project/contract/handover-report/[id]` | ⬜ 대기 |
|
||||
|
||||
#### 💼 판매 (Sales) - 5개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 거래처(영업) | `/sales/client-management-sales-admin/[id]` | ✅ 완료 |
|
||||
| 견적관리 | `/sales/quote-management/[id]` | ⬜ 대기 |
|
||||
| 견적(테스트) | `/sales/quote-management/test/[id]` | ⬜ 대기 |
|
||||
| 판매수주관리 | `/sales/order-management-sales/[id]` | ⬜ 대기 |
|
||||
| 단가관리 | `/sales/pricing-management/[id]` | ⬜ 대기 |
|
||||
|
||||
#### 👥 인사 (HR) - 2개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 카드관리 | `/hr/card-management/[id]` | ✅ 완료 |
|
||||
| 사원관리 | `/hr/employee-management/[id]` | 🔶 복잡 |
|
||||
|
||||
#### 🏭 생산 (Production) - 2개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 작업지시 | `/production/work-orders/[id]` | ⬜ 대기 |
|
||||
| 스크린생산 | `/production/screen-production/[id]` | ⬜ 대기 |
|
||||
|
||||
#### 🔍 품질 (Quality) - 1개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 검수관리 | `/quality/inspections/[id]` | ⬜ 대기 |
|
||||
|
||||
#### 📦 출고 (Outbound) - 1개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 출하관리 | `/outbound/shipments/[id]` | ⬜ 대기 |
|
||||
|
||||
#### 📞 고객센터 (Customer Center) - 1개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| Q&A | `/customer-center/qna/[id]` | 🔶 복잡 |
|
||||
|
||||
#### 📋 게시판 (Board) - 1개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 게시판관리 | `/board/board-management/[id]` | 🔶 복잡 |
|
||||
|
||||
#### ⚙️ 설정 (Settings) - 2개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 계좌관리 | `/settings/accounts/[id]` | ✅ 완료 |
|
||||
| 팝업관리 | `/settings/popup-management/[id]` | ✅ 완료 |
|
||||
|
||||
#### 🔧 기준정보 (Master Data) - 1개
|
||||
|
||||
| 페이지 | 경로 | 폼 공통화 |
|
||||
|--------|------|----------|
|
||||
| 공정관리 | `/master-data/process-management/[id]` | 🔶 복잡 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 통계
|
||||
|
||||
| 구분 | 개수 |
|
||||
|------|------|
|
||||
| ✅ IntegratedDetailTemplate 적용 완료 | 19개 |
|
||||
| 🔶 하위 컴포넌트 위임 (복잡 로직) | 8개 |
|
||||
| ⬜ 개별 구현 (마이그레이션 대기) | 10개 |
|
||||
| **합계** | **37개** |
|
||||
|
||||
---
|
||||
|
||||
### ✅ IntegratedDetailTemplate 적용 완료 (19개)
|
||||
|
||||
config 기반 템플릿으로 완전 마이그레이션 완료된 페이지
|
||||
|
||||
| 페이지 | 경로 | 컴포넌트 |
|
||||
|--------|------|----------|
|
||||
| 입금 | `/accounting/deposits/[id]` | DepositDetailClientV2 |
|
||||
| 출금 | `/accounting/withdrawals/[id]` | WithdrawalDetailClientV2 |
|
||||
| 팝업관리 | `/settings/popup-management/[id]` | PopupDetailClientV2 |
|
||||
| 거래처(영업) | `/sales/client-management-sales-admin/[id]` | ClientDetailClientV2 |
|
||||
| 노무관리 | `/construction/order/base-info/labor/[id]` | LaborDetailClientV2 |
|
||||
| 단가관리 | `/construction/order/base-info/pricing/[id]` | PricingDetailClientV2 |
|
||||
| 계좌관리 | `/settings/accounts/[id]` | accountConfig + IntegratedDetailTemplate |
|
||||
| 카드관리 | `/hr/card-management/[id]` | cardConfig + IntegratedDetailTemplate |
|
||||
| 거래처 | `/accounting/vendors/[id]` | vendorConfig + IntegratedDetailTemplate |
|
||||
| 매출 | `/accounting/sales/[id]` | salesConfig + IntegratedDetailTemplate |
|
||||
| 매입 | `/accounting/purchase/[id]` | purchaseConfig + IntegratedDetailTemplate |
|
||||
| 세금계산서 | `/accounting/bills/[id]` | billConfig + IntegratedDetailTemplate |
|
||||
| 입찰관리 | `/construction/project/bidding/[id]` | biddingConfig + IntegratedDetailTemplate |
|
||||
| 이슈관리 | `/construction/project/issue-management/[id]` | issueConfig + IntegratedDetailTemplate |
|
||||
| 현장설명회 | `/construction/project/bidding/site-briefings/[id]` | siteBriefingConfig + IntegratedDetailTemplate |
|
||||
| 견적서 | `/construction/project/bidding/estimates/[id]` | estimateConfig + IntegratedDetailTemplate |
|
||||
| 협력업체 | `/construction/partners/[id]` | partnerConfig + IntegratedDetailTemplate |
|
||||
| 시공관리 | `/construction/construction-management/[id]` | constructionConfig + IntegratedDetailTemplate |
|
||||
| 기성관리 | `/construction/billing/progress-billing-management/[id]` | progressBillingConfig + IntegratedDetailTemplate |
|
||||
|
||||
---
|
||||
|
||||
### 🔶 하위 컴포넌트 위임 패턴 (8개)
|
||||
|
||||
복잡한 커스텀 로직으로 IntegratedDetailTemplate 적용 검토 필요
|
||||
|
||||
| 페이지 | 경로 | 복잡도 이유 |
|
||||
|--------|------|-------------|
|
||||
| 대손추심 | `/accounting/bad-debt-collection/[id]` | 파일업로드, 메모, 우편번호 |
|
||||
| 게시판관리 | `/board/board-management/[id]` | 하위 컴포넌트 분리 (BoardDetail, BoardForm) |
|
||||
| 공정관리 | `/master-data/process-management/[id]` | 하위 컴포넌트 분리 (ProcessDetail, ProcessForm) |
|
||||
| 현장관리 | `/construction/site-management/[id]` | 목업 데이터, API 미연동 |
|
||||
| 실행내역 | `/construction/order/structure-review/[id]` | 목업 데이터, API 미연동 |
|
||||
| Q&A | `/customer-center/qna/[id]` | 댓글 시스템 포함 |
|
||||
| 사원관리 | `/hr/employee-management/[id]` | 970줄, 우편번호 API, 동적 배열, 프로필 이미지 업로드 |
|
||||
| 품목관리(건설) | `/construction/order/base-info/items/[id]` | 597줄, 동적 발주 항목 배열 관리 |
|
||||
|
||||
---
|
||||
|
||||
### ⬜ 개별 구현 (마이그레이션 대기 - 21개)
|
||||
145
claudedocs/[REF] items-route-consolidation.md
Normal file
145
claudedocs/[REF] items-route-consolidation.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 품목관리 경로 통합 이슈 정리
|
||||
|
||||
> 작성일: 2026-01-20
|
||||
> 브랜치: `feature/universal-detail-component`
|
||||
> 커밋: `6f457b2`
|
||||
|
||||
---
|
||||
|
||||
## 문제 발견
|
||||
|
||||
### 증상
|
||||
- `/production/screen-production` 경로에서 품목 **등록 실패**
|
||||
- `/production/screen-production` 경로에서 품목 **수정 시 기존 값 미표시**
|
||||
|
||||
### 원인 분석
|
||||
|
||||
**중복 경로 존재:**
|
||||
```
|
||||
/items → 신버전 (DynamicItemForm)
|
||||
/production/screen-production → 구버전 (ItemForm)
|
||||
```
|
||||
|
||||
**백엔드 메뉴 설정:**
|
||||
- 사이드바 "생산관리 > 품목관리" 클릭 시 → `/production/screen-production`으로 연결
|
||||
- 메뉴 URL이 API에서 동적으로 관리되어 프론트에서 직접 변경 불가
|
||||
|
||||
**결과:**
|
||||
- 사용자는 항상 `/production/screen-production` (구버전 폼)으로 접속
|
||||
- 구버전 `ItemForm`은 API 필드 매핑이 맞지 않아 등록/수정 오류 발생
|
||||
- 신버전 `DynamicItemForm` (`/items`)은 정상 작동하지만 접근 경로 없음
|
||||
|
||||
---
|
||||
|
||||
## 파일 비교
|
||||
|
||||
### 등록 페이지 (create/page.tsx)
|
||||
|
||||
| 항목 | `/items/create` | `/production/screen-production/create` |
|
||||
|------|-----------------|---------------------------------------|
|
||||
| 폼 컴포넌트 | `DynamicItemForm` | `ItemForm` |
|
||||
| 폼 타입 | 동적 (품목기준관리 API) | 정적 (하드코딩) |
|
||||
| API 매핑 | 정상 | 불일치 |
|
||||
| 상태 | ✅ 정상 작동 | ❌ 등록 오류 |
|
||||
|
||||
### 목록/상세 페이지
|
||||
|
||||
| 항목 | `/items` | `/production/screen-production` |
|
||||
|------|----------|--------------------------------|
|
||||
| 목록 | `ItemListClient` | `ItemListClient` |
|
||||
| 상세 | `ItemDetailView` | `ItemDetailView` |
|
||||
| 수정 | `ItemDetailEdit` | `ItemDetailEdit` |
|
||||
| 상태 | 동일 컴포넌트 공유 | 동일 컴포넌트 공유 |
|
||||
|
||||
**결론:** 목록/상세/수정은 같은 컴포넌트를 공유하지만, **등록만 다른 폼**이 연결되어 있었음
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 선택지
|
||||
|
||||
1. **백엔드 메뉴 URL 변경**: `/production/screen-production` → `/items`
|
||||
- 백엔드 DB 수정 필요
|
||||
- 프론트 단독 작업 불가
|
||||
|
||||
2. **프론트 경로 통합**: `/items` 파일들을 `/production/screen-production`으로 이동 ✅
|
||||
- 백엔드 수정 불필요
|
||||
- 프론트 단독으로 해결 가능
|
||||
|
||||
### 적용한 해결책
|
||||
|
||||
**`/items` → `/production/screen-production` 파일 이동 및 통합**
|
||||
|
||||
```bash
|
||||
# 1. 기존 screen-production 삭제
|
||||
rm -rf src/app/[locale]/(protected)/production/screen-production
|
||||
|
||||
# 2. items 파일들을 screen-production으로 복사
|
||||
cp -r src/app/[locale]/(protected)/items/* \
|
||||
src/app/[locale]/(protected)/production/screen-production/
|
||||
|
||||
# 3. items 폴더 삭제
|
||||
rm -rf src/app/[locale]/(protected)/items
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
### 라우트 파일 (삭제)
|
||||
- `src/app/[locale]/(protected)/items/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/create/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/[id]/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/[id]/edit/page.tsx`
|
||||
|
||||
### 라우트 파일 (신버전으로 교체)
|
||||
- `src/app/[locale]/(protected)/production/screen-production/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/create/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx`
|
||||
|
||||
### 컴포넌트 경로 참조 수정 (`/items` → `/production/screen-production`)
|
||||
| 파일 | 수정 개수 |
|
||||
|------|----------|
|
||||
| `ItemListClient.tsx` | 3개 |
|
||||
| `ItemForm/index.tsx` | 1개 |
|
||||
| `ItemDetailClient.tsx` | 1개 |
|
||||
| `ItemDetailEdit.tsx` | 2개 |
|
||||
| `DynamicItemForm/index.tsx` | 2개 |
|
||||
| **합계** | **9개** |
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
### 문제 원인
|
||||
- 템플릿/테스트용 페이지에 메뉴를 연결한 채로 방치
|
||||
- 신버전 개발 시 구버전 경로 정리 누락
|
||||
- 두 경로가 같은 컴포넌트 일부를 공유해서 문제 파악 지연
|
||||
|
||||
### 예방책
|
||||
1. 신버전 개발 완료 시 구버전 경로 즉시 삭제 또는 리다이렉트 처리
|
||||
2. 메뉴 URL과 실제 라우트 파일 매핑 정기 점검
|
||||
3. 중복 경로 생성 시 명확한 용도 구분 및 문서화
|
||||
|
||||
---
|
||||
|
||||
## 최종 상태
|
||||
|
||||
```
|
||||
/production/screen-production → DynamicItemForm (신버전)
|
||||
/items → 삭제됨
|
||||
```
|
||||
|
||||
**품목관리 CRUD 테스트 결과:**
|
||||
|
||||
| 품목 유형 | Create | Read | Update | Delete |
|
||||
|-----------|--------|------|--------|--------|
|
||||
| 소모품(CS) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 원자재(RM) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부자재(SM) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-구매(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-절곡(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-조립(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 제품(FG) | ✅ | ✅ | ✅ | ✅ |
|
||||
155
claudedocs/dev/[PLAN] detail-page-pattern-classification.md
Normal file
155
claudedocs/dev/[PLAN] detail-page-pattern-classification.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 상세/등록/수정 페이지 패턴 분류표
|
||||
|
||||
> Chrome DevTools MCP로 직접 확인한 결과 기반 (2026-01-19)
|
||||
|
||||
## 패턴 분류 기준
|
||||
|
||||
### 1️⃣ 페이지 형태 - 하단 버튼 (표준 패턴)
|
||||
- URL이 변경되며 별도 페이지로 이동
|
||||
- 버튼 위치: **하단** (좌: 목록/취소, 우: 삭제/수정/저장)
|
||||
- **IntegratedDetailTemplate 적용 대상**
|
||||
|
||||
### 2️⃣ 페이지 형태 - 상단 버튼
|
||||
- URL이 변경되며 별도 페이지로 이동
|
||||
- 버튼 위치: **상단**
|
||||
- IntegratedDetailTemplate 확장 필요 (`buttonPosition="top"`)
|
||||
|
||||
### 3️⃣ 모달 형태
|
||||
- URL 변경 없음, Dialog/Modal로 표시
|
||||
- **IntegratedDetailTemplate 적용 제외**
|
||||
|
||||
### 4️⃣ 인라인 입력 형태
|
||||
- 리스트 페이지 내에서 직접 입력/수정
|
||||
- **IntegratedDetailTemplate 적용 제외**
|
||||
|
||||
### 5️⃣ DynamicForm 형태
|
||||
- API 기반 동적 폼 생성
|
||||
- IntegratedDetailTemplate의 `renderForm` prop으로 분기 처리
|
||||
|
||||
---
|
||||
|
||||
## 📄 페이지 형태 - 하단 버튼 (통합 대상)
|
||||
|
||||
| 도메인 | 페이지 | URL 패턴 | 상태 |
|
||||
|--------|--------|----------|------|
|
||||
| **설정** | 계좌관리 | `/settings/accounts/[id]`, `/new` | ✅ 이미 마이그레이션 완료 |
|
||||
| **설정** | 카드관리 | `/hr/card-management/[id]`, `/new` | ✅ 이미 마이그레이션 완료 |
|
||||
| **설정** | 팝업관리 | `/settings/popup-management/[id]`, `/new` | 🔄 대상 |
|
||||
| **설정** | 게시판관리 | `/board/board-management/[id]`, `/new` | 🔄 대상 |
|
||||
| **기준정보** | 공정관리 | `/master-data/process-management/[id]`, `/new` | 🔄 대상 |
|
||||
| **판매** | 거래처관리 | `/sales/client-management-sales-admin/[id]`, `/new` | 🔄 대상 |
|
||||
| **판매** | 견적관리 | `/sales/quote-management/[id]`, `/new` | 🔄 대상 |
|
||||
| **판매** | 수주관리 | `/sales/order-management-sales/[id]`, `/new` | 🔄 대상 |
|
||||
| **품질** | 검사관리 | `/quality/inspections/[id]`, `/new` | 🔄 대상 |
|
||||
| **출고** | 출하관리 | `/outbound/shipments/[id]`, `/new` | 🔄 대상 |
|
||||
| **고객센터** | 공지사항 | `/customer-center/notices/[id]` | 🔄 대상 |
|
||||
| **고객센터** | 이벤트 | `/customer-center/events/[id]` | 🔄 대상 |
|
||||
|
||||
---
|
||||
|
||||
## 📄 페이지 형태 - 상단 버튼 (확장 필요)
|
||||
|
||||
| 도메인 | 페이지 | URL 패턴 | 버튼 구성 | 비고 |
|
||||
|--------|--------|----------|-----------|------|
|
||||
| **회계** | 거래처관리 | `/accounting/vendors/[id]`, `/new` | 목록/삭제/수정 | 다중 섹션 구조 |
|
||||
| **회계** | 매출관리 | `/accounting/sales/[id]`, `/new` | - | 🔄 대상 |
|
||||
| **회계** | 매입관리 | `/accounting/purchase/[id]` | - | 🔄 대상 |
|
||||
| **회계** | 입금관리 | `/accounting/deposits/[id]` | - | 🔄 대상 |
|
||||
| **회계** | 출금관리 | `/accounting/withdrawals/[id]` | - | 🔄 대상 |
|
||||
| **회계** | 어음관리 | `/accounting/bills/[id]`, `/new` | - | 🔄 대상 |
|
||||
| **회계** | 악성채권 | `/accounting/bad-debt-collection/[id]`, `/new` | - | 🔄 대상 |
|
||||
| **전자결재** | 기안함 (임시저장) | `/approval/draft/new?id=:id&mode=edit` | 상세/삭제/상신/저장 | 복잡한 섹션 구조 |
|
||||
|
||||
---
|
||||
|
||||
## 🔲 모달 형태 (통합 제외)
|
||||
|
||||
| 도메인 | 페이지 | 모달 컴포넌트 | 비고 |
|
||||
|--------|--------|--------------|------|
|
||||
| **설정** | 직급관리 | `RankDialog.tsx` | 인라인 입력 + 수정 모달 |
|
||||
| **설정** | 직책관리 | `TitleDialog.tsx` | 인라인 입력 + 수정 모달 |
|
||||
| **인사** | 부서관리 | `DepartmentDialog.tsx` | 트리 구조 |
|
||||
| **인사** | 근태관리 | `AttendanceInfoDialog.tsx` | 모달로 등록 |
|
||||
| **인사** | 휴가관리 | `VacationRequestDialog.tsx` | 모달로 등록/조정 |
|
||||
| **인사** | 급여관리 | `SalaryDetailDialog.tsx` | 모달로 상세 |
|
||||
| **전자결재** | 기안함 (결재대기) | 품의서 상세 Dialog | 상세만 모달 |
|
||||
| **건설** | 카테고리관리 | `CategoryDialog.tsx` | 모달로 등록/수정 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 DynamicForm 형태 (renderForm 분기)
|
||||
|
||||
| 도메인 | 페이지 | URL 패턴 | 비고 |
|
||||
|--------|--------|----------|------|
|
||||
| **품목** | 품목관리 | `/items/[id]` | `DynamicItemForm` 사용 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 특수 케이스 (개별 처리 필요)
|
||||
|
||||
| 도메인 | 페이지 | URL 패턴 | 특이사항 |
|
||||
|--------|--------|----------|----------|
|
||||
| **설정** | 권한관리 | `/settings/permissions/[id]`, `/new` | Matrix UI, 복잡한 구조 |
|
||||
| **인사** | 사원관리 | `/hr/employee-management/[id]`, `/new` | 40+ 필드, 탭 구조 |
|
||||
| **게시판** | 게시글 | `/board/[boardCode]/[postId]` | 동적 게시판 |
|
||||
| **건설** | 다수 페이지 | `/construction/...` | 별도 분류 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 📊 통합 우선순위
|
||||
|
||||
### Phase 1: 단순 CRUD (우선 작업)
|
||||
1. 팝업관리
|
||||
2. 게시판관리
|
||||
3. 공정관리
|
||||
4. 공지사항/이벤트
|
||||
|
||||
### Phase 2: 중간 복잡도
|
||||
1. 판매 > 거래처관리
|
||||
2. 판매 > 견적관리
|
||||
3. 품질 > 검사관리
|
||||
4. 출고 > 출하관리
|
||||
|
||||
### Phase 3: 회계 도메인 (상단 버튼 확장 후)
|
||||
1. 회계 > 거래처관리
|
||||
2. 회계 > 매출/매입/입금/출금
|
||||
3. 회계 > 어음/악성채권
|
||||
|
||||
### 제외 (개별 유지)
|
||||
- 권한관리 (Matrix UI)
|
||||
- 사원관리 (40+ 필드)
|
||||
- 부서관리 (트리 구조)
|
||||
- 전자결재 (복잡한 워크플로우)
|
||||
- DynamicForm 페이지 (renderForm 분기)
|
||||
- 모달 형태 페이지들
|
||||
|
||||
---
|
||||
|
||||
## IntegratedDetailTemplate 확장 필요 Props
|
||||
|
||||
```typescript
|
||||
interface IntegratedDetailTemplateProps {
|
||||
// 기존 props...
|
||||
|
||||
// 버튼 위치 제어
|
||||
buttonPosition?: 'top' | 'bottom'; // default: 'bottom'
|
||||
|
||||
// 뒤로가기 버튼 표시 여부
|
||||
showBackButton?: boolean; // default: true
|
||||
|
||||
// 상단 버튼 커스텀 (문서 결재 등)
|
||||
headerActions?: ReactNode;
|
||||
|
||||
// 다중 섹션 지원
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
fields: FieldConfig[];
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작성일
|
||||
- 최초 작성: 2026-01-19
|
||||
- Chrome DevTools MCP 확인 완료
|
||||
118
claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md
Normal file
118
claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Chrome DevTools MCP - 이모지 JSON 직렬화 오류
|
||||
|
||||
> 작성일: 2025-01-17
|
||||
|
||||
## 문제 현상
|
||||
|
||||
Chrome DevTools MCP가 특정 페이지 접근 시 다운되는 현상
|
||||
|
||||
### 에러 메시지
|
||||
```
|
||||
API Error: 400 {"type":"error","error":{"type":"invalid_request_error",
|
||||
"message":"The request body is not valid JSON: invalid high surrogate in string:
|
||||
line 1 column XXXXX (char XXXXX)"},"request_id":"req_XXXXX"}
|
||||
```
|
||||
|
||||
### 발생 조건
|
||||
- 페이지에 **이모지**가 많이 포함된 경우
|
||||
- `take_snapshot` 또는 다른 MCP 도구 호출 시
|
||||
- a11y tree를 JSON으로 직렬화하는 과정에서 발생
|
||||
|
||||
## 원인
|
||||
|
||||
### 유니코드 서로게이트 쌍 (Surrogate Pair) 문제
|
||||
|
||||
이모지는 UTF-16에서 **서로게이트 쌍**으로 인코딩됨:
|
||||
- High surrogate: U+D800 ~ U+DBFF
|
||||
- Low surrogate: U+DC00 ~ U+DFFF
|
||||
|
||||
Chrome DevTools MCP가 페이지 스냅샷을 JSON으로 직렬화할 때, 이모지의 서로게이트 쌍이 깨지면서 "invalid high surrogate" 오류 발생.
|
||||
|
||||
### 문제가 되는 케이스
|
||||
1. **DOM에 직접 렌더링된 이모지**: `<span>🏠</span>`
|
||||
2. **데이터에 포함된 이모지**: API 응답, 파싱된 데이터
|
||||
3. **대량의 이모지**: 수십 개 이상의 이모지가 한 페이지에 존재
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 이모지를 Lucide 아이콘으로 교체 (UI)
|
||||
|
||||
**Before**
|
||||
```tsx
|
||||
const iconMap = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
};
|
||||
|
||||
<span className="text-xl">{category.icon}</span>
|
||||
```
|
||||
|
||||
**After**
|
||||
```tsx
|
||||
import { Home, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
const iconComponents: Record<string, LucideIcon> = {
|
||||
Home,
|
||||
Users,
|
||||
};
|
||||
|
||||
function CategoryIcon({ name }: { name: string }) {
|
||||
const IconComponent = iconComponents[name] || FileText;
|
||||
return <IconComponent className="w-5 h-5" />;
|
||||
}
|
||||
|
||||
<CategoryIcon name={category.icon} />
|
||||
```
|
||||
|
||||
### 2. 데이터 파싱 시 이모지 제거/변환 (Server)
|
||||
|
||||
```typescript
|
||||
function convertEmojiToText(text: string): string {
|
||||
// 특정 이모지를 의미있는 텍스트로 변환
|
||||
let result = text
|
||||
.replace(/✅/g, '[완료]')
|
||||
.replace(/⚠️?/g, '[주의]')
|
||||
.replace(/🧪/g, '[테스트]')
|
||||
.replace(/🆕/g, '[NEW]')
|
||||
.replace(/•/g, '-');
|
||||
|
||||
// 모든 이모지 및 특수 유니코드 문자 제거
|
||||
result = result
|
||||
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // 이모지 범위
|
||||
.replace(/[\u{2600}-\u{26FF}]/gu, '') // 기타 기호
|
||||
.replace(/[\u{2700}-\u{27BF}]/gu, '') // 딩뱃
|
||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
|
||||
.replace(/[\u{1F000}-\u{1F02F}]/gu, '') // 마작 타일
|
||||
.replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') // 플레잉 카드
|
||||
.replace(/[\u200D]/g, '') // Zero Width Joiner
|
||||
.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## 체크리스트
|
||||
|
||||
새 페이지 개발 시 Chrome DevTools MCP 호환성 확인:
|
||||
|
||||
- [ ] 페이지에 이모지 직접 렌더링하지 않음
|
||||
- [ ] 아이콘은 Lucide 또는 SVG 사용
|
||||
- [ ] 외부 데이터(API, 파일) 파싱 시 이모지 제거 처리
|
||||
- [ ] status, label 등에 이모지 대신 텍스트 사용
|
||||
|
||||
## 관련 파일
|
||||
|
||||
이 문제로 수정된 파일들:
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `dev/test-urls/actions.ts` | iconMap, convertEmojiToText 함수 추가 |
|
||||
| `dev/test-urls/TestUrlsClient.tsx` | Lucide 아이콘 동적 렌더링 |
|
||||
| `dev/construction-test-urls/actions.ts` | 동일 |
|
||||
| `dev/construction-test-urls/ConstructionTestUrlsClient.tsx` | 동일 |
|
||||
|
||||
## 참고
|
||||
|
||||
- 이 문제는 Chrome DevTools MCP의 JSON 직렬화 로직에서 발생
|
||||
- MCP 자체 버그일 가능성 있으나, 클라이언트에서 이모지 제거로 우회 가능
|
||||
- 다른 MCP 도구에서도 비슷한 문제 발생 가능성 있음
|
||||
495
claudedocs/guides/[PLAN] universal-detail-component.md
Normal file
495
claudedocs/guides/[PLAN] universal-detail-component.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# 상세/등록/수정 페이지 통합 컴포넌트 계획
|
||||
|
||||
> 브랜치: `feature/universal-detail-component`
|
||||
> 작성일: 2026-01-17
|
||||
> 상태: 계획 수립 완료
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목표
|
||||
- 등록/상세/수정 페이지를 **IntegratedDetailTemplate** 통합 컴포넌트로 정리
|
||||
- 기존 API 연결 코드는 그대로 유지 (actions.ts)
|
||||
- UI/레이아웃만 통합하여 **유지보수성 향상**
|
||||
- **미래 동적 폼 전환에 대비한 확장 가능한 설계**
|
||||
|
||||
### 1.2 기대 효과
|
||||
- 코드 중복 제거
|
||||
- UI/UX 일관성 확보 (버튼 위치, 입력필드 스타일, 그리드 레이아웃)
|
||||
- 유지보수 용이성 증가 (한 파일에서 전체 스타일 관리)
|
||||
- 새 페이지 추가 시 개발 시간 단축
|
||||
- 동적 폼 전환 시 껍데기(레이아웃) 재사용 가능
|
||||
|
||||
### 1.3 미래 동적 폼 전환 대비
|
||||
|
||||
> ⚠️ **중요**: 최종 목표는 모든 페이지가 **품목기준관리**처럼 동적 폼으로 전환되는 것
|
||||
|
||||
#### 현재 vs 미래 구조
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ IntegratedDetailTemplate (껍데기 - 재사용) │
|
||||
│ ├── 헤더 레이아웃 ← 동적 폼에서도 사용 │
|
||||
│ ├── 버튼 배치/위치 ← 동적 폼에서도 사용 │
|
||||
│ ├── 그리드 시스템 ← 동적 폼에서도 사용 │
|
||||
│ └── 공통 스타일 ← 동적 폼에서도 사용 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 내부 폼 영역 (교체 가능) │
|
||||
│ │
|
||||
│ 현재: 정적 config 기반 폼 ← 나중에 폐기 │
|
||||
│ 미래: 동적 폼 (기준관리 기반) ← 교체 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 재사용률 분석
|
||||
| 작업 | 동적 폼 전환 시 |
|
||||
|------|----------------|
|
||||
| 헤더/버튼 레이아웃 | ✅ 재사용 (70%) |
|
||||
| 그리드 시스템 | ✅ 재사용 |
|
||||
| 공통 스타일 | ✅ 재사용 |
|
||||
| 정적 config 정의 | ❌ 폐기 (30%) |
|
||||
|
||||
#### 동적 폼 지원 설계
|
||||
```tsx
|
||||
// 현재 (정적 폼)
|
||||
<IntegratedDetailTemplate
|
||||
mode="edit"
|
||||
config={popupConfig} // 필드 정의
|
||||
onSubmit={updatePopup}
|
||||
/>
|
||||
|
||||
// 미래 (동적 폼) - 껍데기는 그대로, 내부만 교체
|
||||
<IntegratedDetailTemplate
|
||||
mode="edit"
|
||||
renderForm={(props) => (
|
||||
<DynamicForm
|
||||
기준관리ID={123}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onSubmit={updatePopup}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 현황 분석
|
||||
|
||||
### 2.1 전체 통계
|
||||
| 항목 | 개수 |
|
||||
|------|------|
|
||||
| 전체 페이지 (page.tsx) | 205개 |
|
||||
| 등록 페이지 (new/create) | 32개 |
|
||||
| 수정 페이지 ([id]/edit) | 30개 |
|
||||
| 상세 페이지 ([id]) | 47개 |
|
||||
| **통합 대상** | **109개** |
|
||||
| **제외 (동적 폼)** | **3개** (품목관리) |
|
||||
|
||||
### 2.2 테스트 URL 페이지
|
||||
- 일반 모듈: http://localhost:3000/dev/test-urls (60개)
|
||||
- 건설 모듈: http://localhost:3000/dev/construction-test-urls (19개)
|
||||
|
||||
---
|
||||
|
||||
## 3. 패턴 분류
|
||||
|
||||
### 3.1 패턴 A: 완전 CRUD (19개 모듈, 57개 페이지)
|
||||
> 구성: `/new` + `/[id]` + `/[id]/edit`
|
||||
|
||||
| 모듈 | 경로 |
|
||||
|------|------|
|
||||
| 사원관리 | hr/employee-management |
|
||||
| 카드관리 | hr/card-management |
|
||||
| 거래처관리 | sales/client-management-sales-admin |
|
||||
| 견적관리 | sales/quote-management |
|
||||
| 수주관리 | sales/order-management-sales |
|
||||
| 단가관리 | sales/pricing-management |
|
||||
| 작업지시관리 | production/work-orders |
|
||||
| 스크린생산 | production/screen-production |
|
||||
| 팝업관리 | settings/popup-management |
|
||||
| 공정관리 | master-data/process-management |
|
||||
| 출하관리 | outbound/shipments |
|
||||
| 검사관리 | quality/inspections |
|
||||
| Q&A | customer-center/qna |
|
||||
| 게시판관리 | board/board-management |
|
||||
| 악성채권추심 | accounting/bad-debt-collection |
|
||||
| 거래처(입찰) | construction/project/bidding/partners |
|
||||
| 현장설명회 | construction/project/bidding/site-briefings |
|
||||
| 계약관리 | construction/project/contract |
|
||||
| 이슈관리 | construction/project/issue-management |
|
||||
|
||||
### 3.2 패턴 B: 등록+상세 (8개 모듈, 16개 페이지)
|
||||
> 구성: `/new` + `/[id]` (수정은 상세에서 mode로 처리)
|
||||
|
||||
| 모듈 | 경로 | 비고 |
|
||||
|------|------|------|
|
||||
| 어음관리 | accounting/bills | mode=edit |
|
||||
| 매출관리 | accounting/sales | mode=edit |
|
||||
| 거래처(회계) | accounting/vendors | mode=edit |
|
||||
| 계좌관리 | settings/accounts | |
|
||||
| 권한관리 | settings/permissions | |
|
||||
| 품목(건설) | construction/order/base-info/items | |
|
||||
| 노임(건설) | construction/order/base-info/labor | |
|
||||
| 단가(건설) | construction/order/base-info/pricing | |
|
||||
|
||||
### 3.3 패턴 C: 상세+수정 (9개 모듈, 18개 페이지)
|
||||
> 구성: `/[id]` + `/[id]/edit` (등록은 리스트에서 처리)
|
||||
|
||||
| 모듈 | 경로 |
|
||||
|------|------|
|
||||
| 기성청구관리 | construction/billing/progress-billing-management |
|
||||
| 발주관리 | construction/order/order-management |
|
||||
| 현장관리 | construction/order/site-management |
|
||||
| 구조검토관리 | construction/order/structure-review |
|
||||
| 입찰관리 | construction/project/bidding |
|
||||
| 견적관리(건설) | construction/project/bidding/estimates |
|
||||
| 시공관리 | construction/project/construction-management |
|
||||
| 인수인계보고서 | construction/project/contract/handover-report |
|
||||
| 견적테스트 | sales/quote-management/test |
|
||||
|
||||
### 3.4 패턴 D: 상세만 (10개 모듈, 10개 페이지)
|
||||
> 구성: `/[id]` only (조회 전용)
|
||||
|
||||
| 모듈 | 경로 | 비고 |
|
||||
|------|------|------|
|
||||
| 입금관리 | accounting/deposits | 조회 전용 |
|
||||
| 매입관리 | accounting/purchase | 조회 전용 |
|
||||
| 거래처원장 | accounting/vendor-ledger | 조회 전용 |
|
||||
| 출금관리 | accounting/withdrawals | 조회 전용 |
|
||||
| 공지사항 | customer-center/notices | 조회 전용 |
|
||||
| 이벤트 | customer-center/events | 조회 전용 |
|
||||
| 입고관리 | material/receiving-management | 조회 전용 |
|
||||
| 재고현황 | material/stock-status | 조회 전용 |
|
||||
| 프로젝트관리 | construction/project/management | 조회 전용 |
|
||||
| 생산주문 | sales/order-management-sales/production-orders | 조회 전용 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 통합 제외 대상
|
||||
|
||||
### 4.1 동적 폼 사용 모듈 (완전 제외)
|
||||
|
||||
> 🔴 **품목관리**는 이미 동적 폼(`DynamicItemForm`)을 사용하므로 **통합 대상에서 완전 제외**
|
||||
|
||||
| 모듈 | 경로 | 제외 이유 |
|
||||
|------|------|----------|
|
||||
| 품목관리 | items | `DynamicItemForm` 사용 (품목기준관리 기반 동적 폼) |
|
||||
|
||||
#### 품목관리 특수성
|
||||
```
|
||||
items/
|
||||
├── create/page.tsx → DynamicItemForm (mode="create") ← 동적 폼
|
||||
├── [id]/page.tsx → ItemDetailClient (카드형 조회) ← 특수 UI
|
||||
└── [id]/edit/page.tsx → DynamicItemForm (mode="edit") ← 동적 폼
|
||||
```
|
||||
|
||||
**제외 이유:**
|
||||
1. **동적 폼 구조**: 필드가 품목기준관리 API 설정에 따라 동적 생성
|
||||
2. **복잡한 비즈니스 로직**: FG/PT/SM/RM/CS 각각 다른 코드 생성 규칙
|
||||
3. **BOM 관리**: 부품표(BOM) 별도 API 호출 및 관리
|
||||
4. **데이터 변환**: 400줄+ 변환 로직 (`mapApiResponseToFormData`)
|
||||
|
||||
> 💡 **미래 방향**: 다른 페이지들도 품목관리처럼 동적 폼으로 전환 예정
|
||||
|
||||
### 4.2 특수 상세 UI 모듈 (상세만 제외)
|
||||
|
||||
> 🟡 상세 페이지가 문서 모달/카드형 등 특수 UI → **상세는 유지, 등록/수정만 통합**
|
||||
|
||||
#### 문서 모달 기반 상세
|
||||
|
||||
| 모듈 | 경로 | 사용 컴포넌트 | 문서 유형 |
|
||||
|------|------|--------------|----------|
|
||||
| 기안함 | approval/draft | `DocumentDetailModal` | 품의서, 지출결의서, 지출예상내역서 |
|
||||
| 수주관리 | sales/order-management-sales | `OrderDocumentModal` | 계약서, 거래명세서, 발주서 |
|
||||
| 견적관리 | sales/quote-management | `QuoteDocument`, `PurchaseOrderDocument` | 견적서, 산출내역서, 발주서 |
|
||||
|
||||
#### 특수 상세 UI
|
||||
|
||||
| 모듈 | 경로 | 사용 컴포넌트 | 특수 요소 |
|
||||
|------|------|--------------|----------|
|
||||
| 작업지시관리 | production/work-orders | `WorkOrderDetail` | 공정 진행 단계(`ProcessSteps`) + `WorkLogModal` |
|
||||
| 스크린생산 | production/screen-production | `ItemDetailClient` | 카드형 조회 (품목 상세) |
|
||||
|
||||
### 4.3 특수 패턴 처리 방안
|
||||
|
||||
```tsx
|
||||
// 특수 상세 UI가 필요한 경우 → renderView prop 사용
|
||||
<IntegratedDetailTemplate
|
||||
mode="view"
|
||||
id={params.id}
|
||||
config={quoteManagementConfig}
|
||||
fetchData={getQuote}
|
||||
// 🔑 커스텀 상세 UI 렌더링
|
||||
renderView={(data) => <QuoteDocumentView data={data} />}
|
||||
/>
|
||||
|
||||
// 등록/수정은 통합 템플릿 사용
|
||||
<IntegratedDetailTemplate
|
||||
mode="create"
|
||||
config={quoteManagementConfig}
|
||||
onSubmit={createQuote}
|
||||
// 기본 폼 UI 사용
|
||||
/>
|
||||
```
|
||||
|
||||
### 4.4 통합 방향 요약
|
||||
|
||||
| 구분 | 상세(view) | 등록(create) | 수정(edit) |
|
||||
|------|-----------|-------------|-----------|
|
||||
| 일반 패턴 | ✅ 통합 | ✅ 통합 | ✅ 통합 |
|
||||
| 특수 상세 UI | ❌ 기존 유지 | ✅ 통합 | ✅ 통합 |
|
||||
| 동적 폼 (품목관리) | ❌ 제외 | ❌ 제외 | ❌ 제외 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 기존 Mode 패턴 분석
|
||||
|
||||
> 이미 mode 파라미터를 사용하는 페이지들 → **디자인만 통일** 필요
|
||||
|
||||
### 5.1 회계 모듈 (URL 파라미터 방식)
|
||||
|
||||
| 모듈 | 경로 | 컴포넌트 | mode 처리 |
|
||||
|------|------|----------|----------|
|
||||
| 어음관리 | accounting/bills | `BillDetail` | `?mode=edit` |
|
||||
| 매출관리 | accounting/sales | `SalesDetail` | `?mode=edit` |
|
||||
| 입금관리 | accounting/deposits | `DepositDetail` | `?mode=view` (조회 전용) |
|
||||
| 매입관리 | accounting/purchase | `PurchaseDetail` | `?mode=view` (조회 전용) |
|
||||
| 거래처관리 | accounting/vendors | `VendorDetail` | `?mode=edit` |
|
||||
| 출금관리 | accounting/withdrawals | `WithdrawalDetail` | `?mode=view` (조회 전용) |
|
||||
|
||||
### 5.2 건설 모듈 (Props 방식)
|
||||
|
||||
| 모듈 | 경로 | 컴포넌트 | mode 처리 |
|
||||
|------|------|----------|----------|
|
||||
| 기성청구관리 | construction/billing | `ProgressBillingDetailForm` | `mode="view"` |
|
||||
| 발주관리 | construction/order | `OrderDetailForm` | `mode="view"` |
|
||||
| 현장관리 | construction/site | `SiteDetailForm` | `mode="view"` |
|
||||
| 구조검토 | construction/structure-review | `StructureReviewDetailForm` | `mode="view"` |
|
||||
| 입찰관리 | construction/bidding | `BiddingDetailForm` | `mode="view"` |
|
||||
| 견적관리(건설) | construction/estimates | `EstimateDetailForm` | `mode="view"` |
|
||||
| 계약관리 | construction/contract | `ContractDetailForm` | `mode="view"` |
|
||||
| 이슈관리 | construction/issue | `IssueDetailForm` | `mode="view"` |
|
||||
| 인수인계 | construction/handover | `HandoverReportDetailForm` | `mode="view"` |
|
||||
|
||||
### 5.3 통합 시 고려사항
|
||||
- URL 파라미터 방식 → Props 방식으로 통일 권장
|
||||
- 기존 컴포넌트 내부 로직은 유지
|
||||
- IntegratedDetailTemplate이 mode를 관리하고 하위 컴포넌트에 전달
|
||||
|
||||
---
|
||||
|
||||
## 6. 통합 컴포넌트 설계
|
||||
|
||||
### 6.1 기본 구조
|
||||
```tsx
|
||||
// IntegratedDetailTemplate
|
||||
interface IntegratedDetailTemplateProps {
|
||||
mode: 'create' | 'edit' | 'view';
|
||||
id?: string;
|
||||
config: DetailPageConfig;
|
||||
|
||||
// API 연결 (기존 actions.ts 그대로 사용)
|
||||
fetchData?: (id: string) => Promise<any>;
|
||||
onSubmit?: (data: any) => Promise<any>;
|
||||
onDelete?: (id: string) => Promise<any>;
|
||||
|
||||
// 🔑 커스텀 렌더링 (특수 케이스 & 미래 동적 폼 대비)
|
||||
renderView?: (data: any) => React.ReactNode;
|
||||
renderForm?: (data: any, mode: 'create' | 'edit') => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DetailPageConfig {
|
||||
title: string;
|
||||
backUrl: string;
|
||||
fields: FieldConfig[];
|
||||
permissions?: {
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 사용 예시
|
||||
|
||||
#### 기본 사용 (정적 폼)
|
||||
```tsx
|
||||
// 통합 방식 (1개 설정 + 3개 라우트)
|
||||
<IntegratedDetailTemplate
|
||||
mode="create"
|
||||
config={popupManagementConfig}
|
||||
onSubmit={createPopup} // 기존 actions.ts
|
||||
/>
|
||||
|
||||
<IntegratedDetailTemplate
|
||||
mode="view"
|
||||
id={params.id}
|
||||
config={popupManagementConfig}
|
||||
fetchData={getPopup} // 기존 actions.ts
|
||||
/>
|
||||
|
||||
<IntegratedDetailTemplate
|
||||
mode="edit"
|
||||
id={params.id}
|
||||
config={popupManagementConfig}
|
||||
fetchData={getPopup} // 기존 actions.ts
|
||||
onSubmit={updatePopup} // 기존 actions.ts
|
||||
/>
|
||||
```
|
||||
|
||||
#### 커스텀 상세 UI
|
||||
```tsx
|
||||
<IntegratedDetailTemplate
|
||||
mode="view"
|
||||
id={params.id}
|
||||
config={quoteConfig}
|
||||
fetchData={getQuote}
|
||||
renderView={(data) => <QuoteDocumentView data={data} />}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 미래: 동적 폼 사용
|
||||
```tsx
|
||||
<IntegratedDetailTemplate
|
||||
mode="edit"
|
||||
id={params.id}
|
||||
fetchData={getData}
|
||||
onSubmit={updateData}
|
||||
renderForm={(data, mode) => (
|
||||
<DynamicForm
|
||||
기준관리ID={123}
|
||||
mode={mode}
|
||||
initialData={data}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 6.3 핵심 원칙
|
||||
- ✅ API 연결 코드 변경 없음 (actions.ts 그대로)
|
||||
- ✅ 기존 기능 100% 유지
|
||||
- ✅ UI/레이아웃만 통합
|
||||
- ✅ 설정(config)만 다르게 전달
|
||||
- ✅ `renderView`/`renderForm`으로 커스텀 렌더링 지원
|
||||
- ✅ 미래 동적 폼 전환 대비 확장 가능한 구조
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 계획
|
||||
|
||||
### Phase 1: 프로토타입 (3개 모듈)
|
||||
> 목표: 통합 템플릿 구조 검증
|
||||
|
||||
| 순서 | 모듈 | 복잡도 | 선정 이유 |
|
||||
|------|------|--------|----------|
|
||||
| 1 | settings/popup-management | 낮음 | 단순 폼 구조 |
|
||||
| 2 | hr/card-management | 낮음 | 기본 CRUD |
|
||||
| 3 | master-data/process-management | 낮음 | 공정 기본 정보 |
|
||||
|
||||
### Phase 2: 설정 모듈 확장 (4개)
|
||||
```
|
||||
settings/accounts
|
||||
settings/permissions
|
||||
board/board-management
|
||||
accounting/bad-debt-collection
|
||||
```
|
||||
|
||||
### Phase 3: HR/판매 모듈 (6개)
|
||||
```
|
||||
hr/employee-management
|
||||
sales/client-management-sales-admin
|
||||
sales/quote-management (등록/수정만, 상세는 renderView)
|
||||
sales/order-management-sales (등록/수정만, 상세는 renderView)
|
||||
sales/pricing-management
|
||||
customer-center/qna
|
||||
```
|
||||
|
||||
### Phase 4: 생산/출고/품질 모듈 (4개)
|
||||
```
|
||||
production/work-orders (등록/수정만, 상세는 renderView)
|
||||
production/screen-production (등록/수정만, 상세는 renderView)
|
||||
outbound/shipments
|
||||
quality/inspections
|
||||
```
|
||||
|
||||
### Phase 5: 건설 모듈 (6개)
|
||||
```
|
||||
construction/project/bidding/partners
|
||||
construction/project/bidding/site-briefings
|
||||
construction/project/contract
|
||||
construction/project/issue-management
|
||||
construction/order/base-info/pricing
|
||||
construction/order/base-info/labor
|
||||
```
|
||||
|
||||
### Phase 6: 나머지 패턴 (B, C, D)
|
||||
- 패턴 B: mode 파라미터 처리 추가
|
||||
- 패턴 C: 등록 없는 케이스 처리
|
||||
- 패턴 D: 조회 전용 모드
|
||||
|
||||
---
|
||||
|
||||
## 8. 파일 구조 계획
|
||||
|
||||
```
|
||||
src/components/templates/
|
||||
├── IntegratedListTemplateV2/ # 기존 리스트 템플릿
|
||||
│ ├── index.tsx
|
||||
│ ├── types.ts
|
||||
│ └── ...
|
||||
│
|
||||
└── IntegratedDetailTemplate/ # 새 상세 템플릿
|
||||
├── index.tsx # 메인 컴포넌트
|
||||
├── types.ts # 타입 정의
|
||||
├── DetailHeader.tsx # 헤더 (제목, 뒤로가기, 액션버튼)
|
||||
├── DetailForm.tsx # 폼 렌더링 (정적 config 기반)
|
||||
├── DetailView.tsx # 조회 모드 렌더링
|
||||
├── FieldRenderer.tsx # 필드 타입별 렌더링
|
||||
├── GridLayout.tsx # 그리드 레이아웃 (2열, 3열)
|
||||
└── hooks/
|
||||
├── useDetailPage.ts # 공통 로직 훅
|
||||
└── useFormHandler.ts # 폼 제출 처리 훅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 마이그레이션 체크리스트
|
||||
|
||||
### 모듈별 마이그레이션 단계
|
||||
- [ ] 기존 컴포넌트 분석
|
||||
- [ ] config 파일 작성
|
||||
- [ ] 등록 페이지 마이그레이션
|
||||
- [ ] 상세 페이지 마이그레이션
|
||||
- [ ] 수정 페이지 마이그레이션
|
||||
- [ ] 기능 테스트
|
||||
- [ ] 기존 파일 정리
|
||||
|
||||
### Phase 1 체크리스트
|
||||
- [ ] IntegratedDetailTemplate 기본 구조 구현
|
||||
- [ ] settings/popup-management 마이그레이션
|
||||
- [ ] hr/card-management 마이그레이션
|
||||
- [ ] master-data/process-management 마이그레이션
|
||||
- [ ] 템플릿 구조 검증 및 수정
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
### 관련 파일
|
||||
- 리스트 템플릿: `src/components/templates/IntegratedListTemplateV2/`
|
||||
- 테스트 URL 목록: `claudedocs/[REF] all-pages-test-urls.md`
|
||||
- 건설 테스트 URL: `claudedocs/[REF] construction-pages-test-urls.md`
|
||||
- 동적 폼 참고: `src/components/items/DynamicItemForm/`
|
||||
|
||||
### 기존 mode 패턴 참고
|
||||
- `accounting/vendors/[id]/page.tsx` → VendorDetail (mode 파라미터)
|
||||
- `accounting/bills/[id]/page.tsx` → BillDetail (mode 파라미터)
|
||||
- `hr/employee-management/[id]/page.tsx` → EmployeeForm (mode 파라미터)
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-01-17 | 초기 계획 수립 |
|
||||
| 2026-01-17 | 특이 패턴 분석 추가 (문서 모달, 특수 상세 UI) |
|
||||
| 2026-01-17 | 품목관리(items) 동적 폼으로 인한 완전 제외 결정 |
|
||||
| 2026-01-17 | 미래 동적 폼 전환 대비 설계 추가 (renderForm prop) |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **작성일**: 2025-12-04
|
||||
> **목적**: 거래처관리 페이지 API 연동 및 sam-design 기준 UI 구현
|
||||
ㅇ> **최종 업데이트**: 2025-12-04 ✅ 구현 완료
|
||||
> **최종 업데이트**: 2025-12-04 ✅ 구현 완료
|
||||
|
||||
---
|
||||
|
||||
|
||||
158
deploy.sh
Executable file
158
deploy.sh
Executable file
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# SAM React 배포 스크립트
|
||||
# 사용법: ./deploy.sh [dev|prod]
|
||||
#
|
||||
|
||||
set -e # 에러 발생 시 중단
|
||||
|
||||
# ===========================================
|
||||
# 설정
|
||||
# ===========================================
|
||||
ENV="${1:-dev}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BUILD_FILE="next-build.tar.gz"
|
||||
|
||||
# 개발 서버 설정
|
||||
DEV_SSH="hskwon@114.203.209.83"
|
||||
DEV_PATH="/home/webservice/react"
|
||||
DEV_PM2="sam-react"
|
||||
|
||||
# 운영 서버 설정 (추후 설정)
|
||||
# PROD_SSH="user@prod-server"
|
||||
# PROD_PATH="/var/www/react"
|
||||
# PROD_PM2="sam-react-prod"
|
||||
|
||||
# 환경별 설정 선택
|
||||
case $ENV in
|
||||
dev)
|
||||
SSH_TARGET=$DEV_SSH
|
||||
REMOTE_PATH=$DEV_PATH
|
||||
PM2_APP=$DEV_PM2
|
||||
;;
|
||||
prod)
|
||||
echo "❌ 운영 환경은 아직 설정되지 않았습니다."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "❌ 알 수 없는 환경: $ENV"
|
||||
echo "사용법: ./deploy.sh [dev|prod]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ===========================================
|
||||
# 함수 정의
|
||||
# ===========================================
|
||||
log() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🚀 $1"
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
error() {
|
||||
echo ""
|
||||
echo "❌ 에러: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 1. 빌드
|
||||
# ===========================================
|
||||
log "Step 1/5: 빌드 시작"
|
||||
|
||||
# .env.local 백업
|
||||
if [ -f .env.local ]; then
|
||||
echo "📦 .env.local 백업..."
|
||||
mv .env.local .env.local.bak
|
||||
fi
|
||||
|
||||
# 빌드 실행
|
||||
echo "🔨 npm run build..."
|
||||
npm run build || {
|
||||
# 빌드 실패 시 .env.local 복원
|
||||
if [ -f .env.local.bak ]; then
|
||||
mv .env.local.bak .env.local
|
||||
fi
|
||||
error "빌드 실패"
|
||||
}
|
||||
|
||||
# .env.local 복원
|
||||
if [ -f .env.local.bak ]; then
|
||||
echo "📦 .env.local 복원..."
|
||||
mv .env.local.bak .env.local
|
||||
fi
|
||||
|
||||
echo "✅ 빌드 완료"
|
||||
|
||||
# ===========================================
|
||||
# 2. 압축
|
||||
# ===========================================
|
||||
log "Step 2/5: 압축 시작"
|
||||
|
||||
# 기존 압축 파일 삭제
|
||||
rm -f $BUILD_FILE
|
||||
|
||||
# .next 폴더 압축 (캐시 제외)
|
||||
echo "📦 .next 폴더 압축 중..."
|
||||
COPYFILE_DISABLE=1 tar --exclude='.next/cache' -czf $BUILD_FILE .next
|
||||
|
||||
# 파일 크기 확인
|
||||
FILE_SIZE=$(ls -lh $BUILD_FILE | awk '{print $5}')
|
||||
echo "✅ 압축 완료: $BUILD_FILE ($FILE_SIZE)"
|
||||
|
||||
# ===========================================
|
||||
# 3. 업로드
|
||||
# ===========================================
|
||||
log "Step 3/5: 서버 업로드"
|
||||
|
||||
echo "📤 $SSH_TARGET:$REMOTE_PATH 로 업로드 중..."
|
||||
scp $BUILD_FILE $SSH_TARGET:$REMOTE_PATH/
|
||||
|
||||
echo "✅ 업로드 완료"
|
||||
|
||||
# ===========================================
|
||||
# 4. 원격 배포 실행
|
||||
# ===========================================
|
||||
log "Step 4/5: 원격 배포 실행"
|
||||
|
||||
echo "🔧 서버에서 배포 스크립트 실행 중..."
|
||||
ssh $SSH_TARGET << EOF
|
||||
cd $REMOTE_PATH
|
||||
|
||||
echo "🗑️ 기존 .next 폴더 삭제..."
|
||||
rm -rf .next
|
||||
|
||||
echo "📦 압축 해제 중..."
|
||||
tar xzf $BUILD_FILE
|
||||
|
||||
echo "🔄 PM2 재시작..."
|
||||
pm2 restart $PM2_APP
|
||||
|
||||
echo "🧹 압축 파일 정리..."
|
||||
rm -f $BUILD_FILE
|
||||
|
||||
echo "✅ 서버 배포 완료"
|
||||
EOF
|
||||
|
||||
# ===========================================
|
||||
# 5. 정리
|
||||
# ===========================================
|
||||
log "Step 5/5: 로컬 정리"
|
||||
|
||||
echo "🧹 로컬 압축 파일 삭제..."
|
||||
rm -f $BUILD_FILE
|
||||
|
||||
# ===========================================
|
||||
# 완료
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🎉 배포 완료!"
|
||||
echo "=========================================="
|
||||
echo "환경: $ENV"
|
||||
echo "서버: $SSH_TARGET"
|
||||
echo "경로: $REMOTE_PATH"
|
||||
echo "시간: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "=========================================="
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -7629,6 +7629,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,58 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
interface EditBadDebtPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /bad-debt-collection/[id]/edit → /bad-debt-collection/[id]?mode=edit
|
||||
*/
|
||||
export default function EditBadDebtPage({ params }: EditBadDebtPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
router.replace(`/ko/accounting/bad-debt-collection/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="edit" recordId={id} initialData={data} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
import { use } from 'react';
|
||||
import { BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection';
|
||||
|
||||
interface BadDebtDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,47 +9,5 @@ interface BadDebtDetailPageProps {
|
||||
|
||||
export default function BadDebtDetailPage({ params }: BadDebtDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="view" recordId={id} initialData={data} />;
|
||||
}
|
||||
return <BadDebtDetailClientV2 recordId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import { BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection';
|
||||
|
||||
export default function NewBadDebtPage() {
|
||||
return <BadDebtDetail mode="new" />;
|
||||
}
|
||||
return <BadDebtDetailClientV2 />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function DepositEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/accounting/deposits/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { DepositDetail } from '@/components/accounting/DepositManagement/DepositDetail';
|
||||
/**
|
||||
* 입금 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
export default function DepositDetailPage() {
|
||||
const params = useParams();
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function DepositDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const depositId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <DepositDetail depositId={depositId} mode={mode} />;
|
||||
return <DepositDetailClientV2 depositId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
export default function DepositNewPage() {
|
||||
return <DepositDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function WithdrawalEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/accounting/withdrawals/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { WithdrawalDetail } from '@/components/accounting/WithdrawalManagement/WithdrawalDetail';
|
||||
/**
|
||||
* 출금 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
export default function WithdrawalDetailPage() {
|
||||
const params = useParams();
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function WithdrawalDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const withdrawalId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <WithdrawalDetail withdrawalId={withdrawalId} mode={mode} />;
|
||||
return <WithdrawalDetailClientV2 withdrawalId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
export default function WithdrawalNewPage() {
|
||||
return <WithdrawalDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -1,116 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
|
||||
import { getBoardById, updateBoard } from '@/components/board/BoardManagement/actions';
|
||||
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Board, BoardFormData } from '@/components/board/BoardManagement/types';
|
||||
/**
|
||||
* 게시판관리 수정 페이지 (리다이렉트)
|
||||
*
|
||||
* 기존 URL 호환성을 위해 유지
|
||||
* /[id]/edit → /[id]?mode=edit 로 리다이렉트
|
||||
*/
|
||||
|
||||
export default function BoardEditPage() {
|
||||
import { useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
export default function BoardEditRedirectPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const fetchBoard = useCallback(async () => {
|
||||
const id = params.id as string;
|
||||
if (!id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getBoardById(id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setBoard(result.data);
|
||||
} else {
|
||||
setError(result.error || '게시판 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [params.id]);
|
||||
const id = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
fetchBoard();
|
||||
}, [fetchBoard]);
|
||||
router.replace(`/ko/board/board-management/${id}?mode=edit`);
|
||||
}, [router, id]);
|
||||
|
||||
const handleSubmit = async (data: BoardFormData) => {
|
||||
if (!board) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await updateBoard(board.id, {
|
||||
...data,
|
||||
boardCode: board.boardCode,
|
||||
description: board.description,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 게시판 수정 성공 시 메뉴 즉시 갱신
|
||||
await forceRefreshMenus();
|
||||
router.push(`/ko/board/board-management/${board.id}`);
|
||||
} else {
|
||||
setError(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error && !board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">게시판을 찾을 수 없습니다.</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<BoardForm
|
||||
mode="edit"
|
||||
board={board}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{isSubmitting && (
|
||||
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
|
||||
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>저장 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <ContentLoadingSpinner text="페이지 이동 중..." />;
|
||||
}
|
||||
|
||||
@@ -1,134 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { BoardDetail } from '@/components/board/BoardManagement/BoardDetail';
|
||||
import { getBoardById, deleteBoard } from '@/components/board/BoardManagement/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Board } from '@/components/board/BoardManagement/types';
|
||||
/**
|
||||
* 게시판관리 상세/수정 페이지
|
||||
*
|
||||
* IntegratedDetailTemplate V2 마이그레이션
|
||||
* - /[id] → 상세 보기 (view 모드)
|
||||
* - /[id]?mode=edit → 수정 (edit 모드)
|
||||
*/
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BoardDetailClientV2 } from '@/components/board/BoardManagement/BoardDetailClientV2';
|
||||
|
||||
export default function BoardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const id = params.id as string;
|
||||
|
||||
const fetchBoard = useCallback(async () => {
|
||||
const id = params.id as string;
|
||||
if (!id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getBoardById(id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setBoard(result.data);
|
||||
} else {
|
||||
setError(result.error || '게시판 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBoard();
|
||||
}, [fetchBoard]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/board/board-management/${params.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!board) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
const result = await deleteBoard(board.id);
|
||||
|
||||
if (result.success) {
|
||||
router.push('/ko/board/board-management');
|
||||
} else {
|
||||
setError(result.error || '삭제에 실패했습니다.');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">{error || '게시판을 찾을 수 없습니다.'}</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoardDetail
|
||||
board={board}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시판 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{board.boardName}" 게시판을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 게시판 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
'삭제'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
return <BoardDetailClientV2 boardId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
|
||||
import { createBoard } from '@/components/board/BoardManagement/actions';
|
||||
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
|
||||
import type { BoardFormData } from '@/components/board/BoardManagement/types';
|
||||
/**
|
||||
* 게시판관리 등록 페이지
|
||||
*
|
||||
* IntegratedDetailTemplate V2 마이그레이션
|
||||
*/
|
||||
|
||||
// 게시판 코드 생성 (임시: 타임스탬프 기반)
|
||||
const generateBoardCode = (): string => {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 6);
|
||||
return `board_${timestamp}_${random}`;
|
||||
};
|
||||
import { BoardDetailClientV2 } from '@/components/board/BoardManagement/BoardDetailClientV2';
|
||||
|
||||
export default function BoardNewPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (data: BoardFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await createBoard({
|
||||
...data,
|
||||
boardCode: generateBoardCode(),
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 게시판 생성 성공 시 메뉴 즉시 갱신
|
||||
await forceRefreshMenus();
|
||||
router.push('/ko/board/board-management');
|
||||
} else {
|
||||
setError(result.error || '게시판 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<BoardForm
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{isSubmitting && (
|
||||
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
|
||||
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>등록 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
export default function BoardCreatePage() {
|
||||
return <BoardDetailClientV2 boardId="new" />;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ export default function DynamicBoardListPage() {
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
headerActions: (
|
||||
headerActions: () => (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
@@ -414,7 +414,10 @@ export default function DynamicBoardListPage() {
|
||||
}}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,57 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
|
||||
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
|
||||
|
||||
interface ProgressBillingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /progress-billing-management/[id]/edit → /progress-billing-management/[id]?mode=edit
|
||||
*/
|
||||
export default function ProgressBillingEditPage({ params }: ProgressBillingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getProgressBillingDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('기성청구 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
router.replace(`/ko/construction/billing/progress-billing-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="edit" billingId={id} initialData={data} />;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { use, useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
|
||||
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface ProgressBillingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -11,12 +12,19 @@ interface ProgressBillingDetailPageProps {
|
||||
|
||||
export default function ProgressBillingDetailPage({ params }: ProgressBillingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
getProgressBillingDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
@@ -31,6 +39,10 @@ export default function ProgressBillingDetailPage({ params }: ProgressBillingDet
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -41,17 +53,21 @@ export default function ProgressBillingDetailPage({ params }: ProgressBillingDet
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="기성청구 정보를 불러올 수 없습니다"
|
||||
message={error || '기성청구 정보를 찾을 수 없습니다.'}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="view" billingId={id} initialData={data} />;
|
||||
return (
|
||||
<ProgressBillingDetailForm
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
billingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,7 +13,7 @@ export default function LaborDetailPage({ params }: LaborDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
const initialMode: DetailMode = mode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
return <LaborDetailClientV2 laborId={id} initialMode={initialMode} />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
'use client';
|
||||
|
||||
import { LaborDetailClientV2 } from '@/components/business/construction/labor-management';
|
||||
|
||||
export default function LaborNewPage() {
|
||||
return <LaborDetailClient isNewMode />;
|
||||
return <LaborDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
useEffect(() => {
|
||||
router.replace(`/construction/order/base-info/pricing/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -9,6 +15,8 @@ interface PageProps {
|
||||
|
||||
export default function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
return <PricingDetailClientV2 pricingId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
'use client';
|
||||
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
export default function PricingNewPage() {
|
||||
return <PricingDetailClient mode="create" />;
|
||||
return <PricingDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -1,57 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
router.replace(`/construction/order/order-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={data} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 발주 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,6 +18,8 @@ interface OrderDetailPageProps {
|
||||
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,17 +49,14 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="발주 정보를 불러올 수 없습니다"
|
||||
message={error || '발주 정보를 찾을 수 없습니다.'}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={data} />;
|
||||
return <OrderDetailForm mode={mode} orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,43 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
interface EditSitePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function SiteEditPage({ params }: PageProps) {
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /site-management/[id]/edit → /site-management/[id]?mode=edit
|
||||
*/
|
||||
export default function EditSitePage({ params }: EditSitePageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
router.replace(`/ko/construction/order/site-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
/**
|
||||
* 현장관리 상세 페이지 (V2)
|
||||
*
|
||||
* /site-management/[id] → 조회 모드
|
||||
* /site-management/[id]?mode=edit → 수정 모드
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
import { use } from 'react';
|
||||
import { SiteDetailClientV2 } from '@/components/business/construction/site-management/SiteDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
interface SiteDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function SiteDetailPage({ params }: PageProps) {
|
||||
export default function SiteDetailPage({ params }: SiteDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
return <SiteDetailClientV2 siteId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
interface EditStructureReviewPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function StructureReviewEditPage({ params }: PageProps) {
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /structure-review/[id]/edit → /structure-review/[id]?mode=edit
|
||||
*/
|
||||
export default function EditStructureReviewPage({ params }: EditStructureReviewPageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
router.replace(`/ko/construction/order/structure-review/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
/**
|
||||
* 구조검토 상세 페이지 (V2)
|
||||
*
|
||||
* /structure-review/[id] → 조회 모드
|
||||
* /structure-review/[id]?mode=edit → 수정 모드
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
import { use } from 'react';
|
||||
import { StructureReviewDetailClientV2 } from '@/components/business/construction/structure-review/StructureReviewDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
interface StructureReviewDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function StructureReviewDetailPage({ params }: PageProps) {
|
||||
export default function StructureReviewDetailPage({ params }: StructureReviewDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
return <StructureReviewDetailClientV2 reviewId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface BiddingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /bidding/[id]/edit → /bidding/[id]?mode=edit
|
||||
*/
|
||||
export default function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface BiddingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -9,17 +11,37 @@ interface BiddingDetailPageProps {
|
||||
|
||||
export default function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('입찰 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('입찰 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -28,9 +50,21 @@ export default function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="입찰 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
biddingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,61 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
import { getEstimateDetail } from '@/components/business/construction/estimates/actions';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface EstimateEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /estimates/[id]/edit → /estimates/[id]?mode=edit
|
||||
*/
|
||||
export default function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getEstimateDetail(id);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '견적 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/estimates/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
import { getEstimateDetail } from '@/components/business/construction/estimates/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface EstimateDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -11,30 +13,37 @@ interface EstimateDetailPageProps {
|
||||
|
||||
export default function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getEstimateDetail(id);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '견적 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getEstimateDetail(id);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '견적 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -45,15 +54,19 @@ export default function EstimateDetailPage({ params }: EstimateDetailPageProps)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="견적 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
estimateId={id}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
|
||||
@@ -1,60 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
|
||||
interface PartnerEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /partners/[id]/edit → /partners/[id]?mode=edit
|
||||
*/
|
||||
export default function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPartner(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('협력업체 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/partners/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="edit"
|
||||
partnerId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { use, useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface PartnerDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -11,12 +12,19 @@ interface PartnerDetailPageProps {
|
||||
|
||||
export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
getPartner(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
@@ -31,6 +39,10 @@ export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -41,18 +53,19 @@ export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="협력업체 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
partnerId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,59 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
|
||||
interface SiteBriefingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /site-briefings/[id]/edit → /site-briefings/[id]?mode=edit
|
||||
*/
|
||||
export default function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteBriefing(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('현장 설명회 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/site-briefings/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="edit"
|
||||
briefingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { use, useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface SiteBriefingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -10,12 +11,19 @@ interface SiteBriefingDetailPageProps {
|
||||
|
||||
export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
getSiteBriefing(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
@@ -30,6 +38,10 @@ export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPag
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -40,18 +52,19 @@ export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPag
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="현장 설명회 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
briefingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -9,8 +9,21 @@ interface PageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /construction-management/[id]/edit → /construction-management/[id]?mode=edit
|
||||
*/
|
||||
export default function ConstructionManagementEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
return <ConstructionDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
useEffect(() => {
|
||||
router.replace(`/ko/construction/project/construction-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
@@ -11,6 +12,11 @@ interface PageProps {
|
||||
|
||||
export default function ConstructionManagementDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <ConstructionDetailClient id={id} mode="view" />;
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ConstructionDetailClient id={id} mode={isEditMode ? 'edit' : 'view'} />;
|
||||
}
|
||||
|
||||
@@ -1,60 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('계약 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('계약 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/construction/project/contract/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,6 +13,8 @@ interface ContractDetailPageProps {
|
||||
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,18 +44,18 @@ export default function ContractDetailPage({ params }: ContractDetailPageProps)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="계약 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
mode={mode}
|
||||
contractId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
params: Promise<{
|
||||
@@ -11,52 +10,20 @@ interface HandoverReportEditPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('인수인계서 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/construction/project/contract/handover-report/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -14,6 +15,8 @@ interface HandoverReportDetailPageProps {
|
||||
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -43,18 +46,18 @@ export default function HandoverReportDetailPage({ params }: HandoverReportDetai
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="인수인계서 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
mode={mode}
|
||||
reportId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||
import { getIssue } from '@/components/business/construction/issue-management/actions';
|
||||
import type { Issue } from '@/components/business/construction/issue-management/types';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /issue-management/[id]/edit → /issue-management/[id]?mode=edit
|
||||
*/
|
||||
export default function IssueEditPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [issue, setIssue] = useState<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const result = await getIssue(id);
|
||||
if (result.success && result.data) {
|
||||
setIssue(result.data);
|
||||
} else {
|
||||
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('이슈 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
router.replace(`/ko/construction/project/issue-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||
import { getIssue } from '@/components/business/construction/issue-management/actions';
|
||||
import type { Issue } from '@/components/business/construction/issue-management/types';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
export default function IssueDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const id = params.id as string;
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const [issue, setIssue] = useState<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const result = await getIssue(id);
|
||||
if (result.success && result.data) {
|
||||
setIssue(result.data);
|
||||
} else {
|
||||
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('이슈 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getIssue(id);
|
||||
if (result.success && result.data) {
|
||||
setIssue(result.data);
|
||||
} else {
|
||||
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
} catch {
|
||||
setError('이슈 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -43,11 +51,15 @@ export default function IssueDetailPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="이슈 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="view" />;
|
||||
return <IssueDetailForm issue={issue} mode={isEditMode ? 'edit' : 'view'} />;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EventDetail } from '@/components/customer-center/EventManagement';
|
||||
import { transformPostToEvent, type Event } from '@/components/customer-center/EventManagement/types';
|
||||
import { getPost } from '@/components/customer-center/shared/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -14,38 +15,42 @@ export default function EventDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEvent() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const fetchEvent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getPost('events', eventId);
|
||||
const result = await getPost('events', eventId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setEvent(transformPostToEvent(result.data));
|
||||
} else {
|
||||
setError(result.error || '이벤트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
if (result.success && result.data) {
|
||||
setEvent(transformPostToEvent(result.data));
|
||||
} else {
|
||||
setError(result.error || '이벤트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
fetchEvent();
|
||||
setIsLoading(false);
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvent();
|
||||
}, [fetchEvent]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">{error || '이벤트를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="이벤트를 불러올 수 없습니다"
|
||||
message={error || '이벤트를 찾을 수 없습니다.'}
|
||||
onRetry={fetchEvent}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { NoticeDetail } from '@/components/customer-center/NoticeManagement';
|
||||
import { transformPostToNotice, type Notice } from '@/components/customer-center/NoticeManagement/types';
|
||||
import { getPost } from '@/components/customer-center/shared/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
export default function NoticeDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -14,38 +15,42 @@ export default function NoticeDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNotice() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const fetchNotice = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getPost('notices', id);
|
||||
const result = await getPost('notices', id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setNotice(transformPostToNotice(result.data));
|
||||
} else {
|
||||
setError(result.error || '공지사항을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
if (result.success && result.data) {
|
||||
setNotice(transformPostToNotice(result.data));
|
||||
} else {
|
||||
setError(result.error || '공지사항을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
fetchNotice();
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotice();
|
||||
}, [fetchNotice]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notice) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">{error || '공지사항을 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="공지사항을 불러올 수 없습니다"
|
||||
message={error || '공지사항을 찾을 수 없습니다.'}
|
||||
onRetry={fetchNotice}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface EditInquiryPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1:1 문의 수정 페이지
|
||||
* 하위 호환성을 위한 리다이렉트 페이지
|
||||
* /qna/[id]/edit → /qna/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { InquiryForm } from '@/components/customer-center/InquiryManagement';
|
||||
import { transformPostToInquiry, type Inquiry } from '@/components/customer-center/InquiryManagement/types';
|
||||
import { getPost } from '@/components/customer-center/shared/actions';
|
||||
|
||||
export default function InquiryEditPage() {
|
||||
const params = useParams();
|
||||
const inquiryId = params.id as string;
|
||||
|
||||
const [inquiry, setInquiry] = useState<Inquiry | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export default function EditInquiryPage({ params }: EditInquiryPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchInquiry() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
router.replace(`/ko/customer-center/qna/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
const result = await getPost('qna', inquiryId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setInquiry(transformPostToInquiry(result.data));
|
||||
} else {
|
||||
setError(result.error || '문의를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchInquiry();
|
||||
}, [inquiryId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !inquiry) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error || '문의를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InquiryForm mode="edit" initialData={inquiry} />;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,135 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 1:1 문의 상세 페이지
|
||||
* 1:1 문의 상세 페이지 (V2)
|
||||
*
|
||||
* /qna/[id] → 조회 모드
|
||||
* /qna/[id]?mode=edit → 수정 모드
|
||||
*/
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { InquiryDetail } from '@/components/customer-center/InquiryManagement';
|
||||
import { transformPostToInquiry, type Inquiry, type Comment } from '@/components/customer-center/InquiryManagement/types';
|
||||
import { getPost, getComments, createComment, updateComment, deleteComment, deletePost } from '@/components/customer-center/shared/actions';
|
||||
import { transformApiToComment } from '@/components/customer-center/shared/types';
|
||||
import { use } from 'react';
|
||||
import { InquiryDetailClientV2 } from '@/components/customer-center/InquiryManagement';
|
||||
|
||||
export default function InquiryDetailPage() {
|
||||
const params = useParams();
|
||||
const inquiryId = params.id as string;
|
||||
|
||||
const [inquiry, setInquiry] = useState<Inquiry | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||
|
||||
// 현재 사용자 ID 가져오기 (localStorage에서)
|
||||
useEffect(() => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
// user.id는 실제 DB user ID (숫자)
|
||||
setCurrentUserId(String(user.id || ''));
|
||||
} catch {
|
||||
setCurrentUserId('');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 게시글과 댓글 동시 로드
|
||||
const [postResult, commentsResult] = await Promise.all([
|
||||
getPost('qna', inquiryId),
|
||||
getComments('qna', inquiryId),
|
||||
]);
|
||||
|
||||
if (postResult.success && postResult.data) {
|
||||
setInquiry(transformPostToInquiry(postResult.data));
|
||||
} else {
|
||||
setError(postResult.error || '문의를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (commentsResult.success && commentsResult.data) {
|
||||
setComments(commentsResult.data.map(transformApiToComment));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [inquiryId]);
|
||||
|
||||
// 댓글 추가
|
||||
const handleAddComment = useCallback(async (content: string) => {
|
||||
const result = await createComment('qna', inquiryId, content);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) => [...prev, transformApiToComment(result.data!)]);
|
||||
} else {
|
||||
console.error('댓글 등록 실패:', result.error);
|
||||
}
|
||||
}, [inquiryId]);
|
||||
|
||||
// 댓글 수정
|
||||
const handleUpdateComment = useCallback(async (commentId: string, content: string) => {
|
||||
const result = await updateComment('qna', inquiryId, commentId, content);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c))
|
||||
);
|
||||
} else {
|
||||
console.error('댓글 수정 실패:', result.error);
|
||||
}
|
||||
}, [inquiryId]);
|
||||
|
||||
// 댓글 삭제
|
||||
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||
const result = await deleteComment('qna', inquiryId, commentId);
|
||||
if (result.success) {
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} else {
|
||||
console.error('댓글 삭제 실패:', result.error);
|
||||
}
|
||||
}, [inquiryId]);
|
||||
|
||||
// 문의 삭제
|
||||
const handleDeleteInquiry = useCallback(async () => {
|
||||
const result = await deletePost('qna', inquiryId);
|
||||
return result.success;
|
||||
}, [inquiryId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !inquiry) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error || '문의를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 답변은 추후 API 추가 시 구현
|
||||
const reply = undefined;
|
||||
|
||||
return (
|
||||
<InquiryDetail
|
||||
inquiry={inquiry}
|
||||
reply={reply}
|
||||
comments={comments}
|
||||
currentUserId={currentUserId}
|
||||
onAddComment={handleAddComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onDeleteInquiry={handleDeleteInquiry}
|
||||
/>
|
||||
);
|
||||
interface InquiryDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function InquiryDetailPage({ params }: InquiryDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <InquiryDetailClientV2 inquiryId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 1:1 문의 등록 페이지
|
||||
* 1:1 문의 등록 페이지 (V2)
|
||||
*/
|
||||
|
||||
import { InquiryForm } from '@/components/customer-center/InquiryManagement';
|
||||
import { InquiryDetailClientV2 } from '@/components/customer-center/InquiryManagement';
|
||||
|
||||
export default function InquiryCreatePage() {
|
||||
return <InquiryForm mode="create" />;
|
||||
return <InquiryDetailClientV2 />;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '1:1 문의 등록',
|
||||
description: '1:1 문의를 등록합니다.',
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import WelcomeMessage from '@/components/WelcomeMessage';
|
||||
import NavigationMenu from '@/components/NavigationMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dashboard Page with Internationalization
|
||||
*
|
||||
* Note: Authentication protection is handled by (protected)/layout.tsx
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('common');
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// ✅ HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨');
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 처리 중 오류:', error);
|
||||
// 에러가 나도 로그인 페이지로 이동
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header with Language Switcher */}
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('appName')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<WelcomeMessage />
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{t('appName')} Modules
|
||||
</h2>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
|
||||
{/* Information Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Multi-language Support
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
This ERP system supports Korean (한국어), English, and Japanese (日本語).
|
||||
Use the language switcher above to change the interface language.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Developer Info */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
For Developers
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Check the documentation in <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">claudedocs/i18n-usage-guide.md</code>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Message files: <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">src/messages/</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Link,
|
||||
Home,
|
||||
Monitor,
|
||||
BarChart3,
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Lucide 아이콘 매핑 (이모지 대신 사용 - Chrome DevTools MCP JSON 직렬화 오류 방지)
|
||||
const iconComponents: Record<string, LucideIcon> = {
|
||||
Home,
|
||||
Monitor,
|
||||
BarChart3,
|
||||
FileText,
|
||||
};
|
||||
|
||||
function CategoryIcon({ name, className }: { name: string; className?: string }) {
|
||||
const IconComponent = iconComponents[name] || FileText;
|
||||
return <IconComponent className={className} />;
|
||||
}
|
||||
|
||||
export interface UrlItem {
|
||||
name: string;
|
||||
@@ -97,7 +123,7 @@ function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
<span className="text-xl">{category.icon}</span>
|
||||
<CategoryIcon name={category.icon} className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{category.title}
|
||||
</h2>
|
||||
@@ -196,8 +222,9 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
🏭 주일기업 테스트 URL 목록
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<Link className="w-6 h-6 text-blue-500" />
|
||||
주일기업 테스트 URL 목록
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
@@ -212,10 +239,10 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
|
||||
주일기업용 백엔드 메뉴 연동 전 테스트용 직접 접근 URL ({totalLinks}개)
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
클릭하면 새 탭에서 열립니다 • 최종 업데이트: {lastUpdated}
|
||||
클릭하면 새 탭에서 열립니다 - 최종 업데이트: {lastUpdated}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
✨ md 파일 수정 시 자동 반영됩니다
|
||||
md 파일 수정 시 자동 반영됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +282,7 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
📁 데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] construction-pages-test-urls.md</code>
|
||||
데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] construction-pages-test-urls.md</code>
|
||||
</p>
|
||||
<p className="mt-1 text-green-600 dark:text-green-400">
|
||||
md 파일 수정 후 새로고침하면 자동 반영!
|
||||
|
||||
@@ -4,18 +4,43 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
// 아이콘 매핑 (Lucide 아이콘 이름 사용 - 이모지는 Chrome DevTools MCP에서 JSON 직렬화 오류 발생)
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
'기본': 'Home',
|
||||
'시스템': 'Monitor',
|
||||
'대시보드': 'BarChart3',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
return 'FileText';
|
||||
}
|
||||
|
||||
// 이모지를 텍스트로 변환 (Chrome DevTools MCP JSON 직렬화 오류 방지)
|
||||
function convertEmojiToText(text: string): string {
|
||||
// 먼저 특정 이모지를 의미있는 텍스트로 변환
|
||||
let result = text
|
||||
.replace(/✅/g, '[완료]')
|
||||
.replace(/⚠️?/g, '[주의]')
|
||||
.replace(/🧪/g, '[테스트]')
|
||||
.replace(/🆕/g, '[NEW]')
|
||||
.replace(/•/g, '-');
|
||||
|
||||
// 나머지 모든 이모지 및 특수 유니코드 문자 제거
|
||||
// Unicode emoji 범위와 variation selectors 제거
|
||||
result = result
|
||||
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // 이모지 범위
|
||||
.replace(/[\u{2600}-\u{26FF}]/gu, '') // 기타 기호
|
||||
.replace(/[\u{2700}-\u{27BF}]/gu, '') // 딩뱃
|
||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
|
||||
.replace(/[\u{1F000}-\u{1F02F}]/gu, '') // 마작 타일
|
||||
.replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') // 플레잉 카드
|
||||
.replace(/[\u200D]/g, '') // Zero Width Joiner
|
||||
.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
@@ -25,9 +50,10 @@ function parseTableRow(line: string): UrlItem | null {
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const name = convertEmojiToText(parts[0].replace(/\*\*/g, '')); // **bold** 제거 + 이모지 변환
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
const rawStatus = parts[2] || '';
|
||||
const status = convertEmojiToText(rawStatus) || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
@@ -63,7 +89,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
const title = convertEmojiToText(line.replace('## ', ''));
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
@@ -81,7 +107,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
const subTitle = convertEmojiToText(line.replace('### ', ''));
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
@@ -128,6 +154,7 @@ export async function getConstructionTestUrlsData(): Promise<{ categories: UrlCa
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'dev',
|
||||
'[REF] construction-pages-test-urls.md'
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Link,
|
||||
Home,
|
||||
Users,
|
||||
DollarSign,
|
||||
Package,
|
||||
Factory,
|
||||
Boxes,
|
||||
FlaskConical,
|
||||
PackageCheck,
|
||||
Settings,
|
||||
FileText,
|
||||
Wallet,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
User,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Headphones,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Lucide 아이콘 매핑 (이모지 대신 사용 - Chrome DevTools MCP JSON 직렬화 오류 방지)
|
||||
const iconComponents: Record<string, LucideIcon> = {
|
||||
Home,
|
||||
Users,
|
||||
DollarSign,
|
||||
Package,
|
||||
Factory,
|
||||
Boxes,
|
||||
FlaskConical,
|
||||
PackageCheck,
|
||||
Settings,
|
||||
FileText,
|
||||
Wallet,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
User,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Headphones,
|
||||
};
|
||||
|
||||
function CategoryIcon({ name, className }: { name: string; className?: string }) {
|
||||
const IconComponent = iconComponents[name] || FileText;
|
||||
return <IconComponent className={className} />;
|
||||
}
|
||||
|
||||
export interface UrlItem {
|
||||
name: string;
|
||||
@@ -97,7 +149,7 @@ function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
<span className="text-xl">{category.icon}</span>
|
||||
<CategoryIcon name={category.icon} className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{category.title}
|
||||
</h2>
|
||||
@@ -196,8 +248,9 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
🔗 테스트 URL 목록
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<Link className="w-6 h-6 text-blue-500" />
|
||||
테스트 URL 목록
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
@@ -212,10 +265,10 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
|
||||
백엔드 메뉴 연동 전 테스트용 직접 접근 URL ({totalLinks}개)
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
클릭하면 새 탭에서 열립니다 • 최종 업데이트: {lastUpdated}
|
||||
클릭하면 새 탭에서 열립니다 - 최종 업데이트: {lastUpdated}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
✨ md 파일 수정 시 자동 반영됩니다
|
||||
md 파일 수정 시 자동 반영됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +308,7 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
📁 데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] all-pages-test-urls.md</code>
|
||||
데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] all-pages-test-urls.md</code>
|
||||
</p>
|
||||
<p className="mt-1 text-green-600 dark:text-green-400">
|
||||
md 파일 수정 후 새로고침하면 자동 반영!
|
||||
|
||||
@@ -4,34 +4,70 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './TestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
// 아이콘 매핑 (Lucide 아이콘 이름 사용 - 이모지는 Chrome DevTools MCP에서 JSON 직렬화 오류 발생)
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
'HR': '👥',
|
||||
'판매관리': '💰',
|
||||
'Sales': '💰',
|
||||
'기준정보관리': '📦',
|
||||
'Master Data': '📦',
|
||||
'생산관리': '🏭',
|
||||
'Production': '🏭',
|
||||
'설정': '⚙️',
|
||||
'Settings': '⚙️',
|
||||
'전자결재': '📝',
|
||||
'Approval': '📝',
|
||||
'회계관리': '💵',
|
||||
'Accounting': '💵',
|
||||
'게시판': '📋',
|
||||
'Board': '📋',
|
||||
'보고서': '📊',
|
||||
'Reports': '📊',
|
||||
'기본': 'Home',
|
||||
'인사관리': 'Users',
|
||||
'HR': 'Users',
|
||||
'판매관리': 'DollarSign',
|
||||
'Sales': 'DollarSign',
|
||||
'기준정보관리': 'Package',
|
||||
'Master Data': 'Package',
|
||||
'생산관리': 'Factory',
|
||||
'Production': 'Factory',
|
||||
'자재관리': 'Boxes',
|
||||
'Material': 'Boxes',
|
||||
'품질관리': 'FlaskConical',
|
||||
'Quality': 'FlaskConical',
|
||||
'출고관리': 'PackageCheck',
|
||||
'Outbound': 'PackageCheck',
|
||||
'설정': 'Settings',
|
||||
'Settings': 'Settings',
|
||||
'전자결재': 'FileText',
|
||||
'Approval': 'FileText',
|
||||
'회계관리': 'Wallet',
|
||||
'Accounting': 'Wallet',
|
||||
'게시판': 'ClipboardList',
|
||||
'Board': 'ClipboardList',
|
||||
'보고서': 'BarChart3',
|
||||
'Reports': 'BarChart3',
|
||||
'계정': 'User',
|
||||
'회사': 'Building2',
|
||||
'구독': 'CreditCard',
|
||||
'고객센터': 'Headphones',
|
||||
'Customer': 'Headphones',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
return 'FileText';
|
||||
}
|
||||
|
||||
// 이모지를 텍스트로 변환 (Chrome DevTools MCP JSON 직렬화 오류 방지)
|
||||
function convertEmojiToText(text: string): string {
|
||||
// 먼저 특정 이모지를 의미있는 텍스트로 변환
|
||||
let result = text
|
||||
.replace(/✅/g, '[완료]')
|
||||
.replace(/⚠️?/g, '[주의]')
|
||||
.replace(/🧪/g, '[테스트]')
|
||||
.replace(/🆕/g, '[NEW]')
|
||||
.replace(/•/g, '-');
|
||||
|
||||
// 나머지 모든 이모지 및 특수 유니코드 문자 제거
|
||||
// Unicode emoji 범위와 variation selectors 제거
|
||||
result = result
|
||||
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // 이모지 범위
|
||||
.replace(/[\u{2600}-\u{26FF}]/gu, '') // 기타 기호
|
||||
.replace(/[\u{2700}-\u{27BF}]/gu, '') // 딩뱃
|
||||
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
|
||||
.replace(/[\u{1F000}-\u{1F02F}]/gu, '') // 마작 타일
|
||||
.replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') // 플레잉 카드
|
||||
.replace(/[\u200D]/g, '') // Zero Width Joiner
|
||||
.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
@@ -41,9 +77,10 @@ function parseTableRow(line: string): UrlItem | null {
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const name = convertEmojiToText(parts[0].replace(/\*\*/g, '')); // **bold** 제거 + 이모지 변환
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
const rawStatus = parts[2] || '';
|
||||
const status = convertEmojiToText(rawStatus) || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
@@ -79,7 +116,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
const title = convertEmojiToText(line.replace('## ', ''));
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
@@ -97,7 +134,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
const subTitle = convertEmojiToText(line.replace('### ', ''));
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
@@ -144,6 +181,7 @@ export async function getTestUrlsData(): Promise<{ categories: UrlCategory[]; la
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'dev',
|
||||
'[REF] all-pages-test-urls.md'
|
||||
);
|
||||
|
||||
|
||||
@@ -1,63 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 카드 수정 페이지 - 상세 페이지로 리다이렉트
|
||||
*
|
||||
* IntegratedDetailTemplate 통합으로 인해 [id]?mode=edit로 처리됨
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CardForm } from '@/components/hr/CardManagement/CardForm';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import { getCard, updateCard } from '@/components/hr/CardManagement/actions';
|
||||
|
||||
export default function CardEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [card, setCard] = useState<Card | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCard = async () => {
|
||||
if (!params.id) return;
|
||||
// 상세 페이지의 edit 모드로 리다이렉트
|
||||
router.replace(`/ko/hr/card-management/${params.id}?mode=edit`);
|
||||
}, [router, params.id]);
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await getCard(params.id as string);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadCard();
|
||||
}, [params.id, router]);
|
||||
|
||||
const handleSubmit = async (data: CardFormData) => {
|
||||
if (!params.id) return;
|
||||
|
||||
setIsSaving(true);
|
||||
const result = await updateCard(params.id as string, data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('카드가 수정되었습니다.');
|
||||
router.push(`/ko/hr/card-management/${params.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
if (isLoading || !card) {
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardForm
|
||||
mode="edit"
|
||||
card={card}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||
/**
|
||||
* 카드 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { Card } from '@/components/hr/CardManagement/types';
|
||||
import { getCard, deleteCard } from '@/components/hr/CardManagement/actions';
|
||||
getCard,
|
||||
updateCard,
|
||||
deleteCard,
|
||||
} from '@/components/hr/CardManagement/actions';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
export default function CardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const cardId = params.id as string;
|
||||
|
||||
const [card, setCard] = useState<Card | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
||||
const urlMode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadCard = async () => {
|
||||
if (!params.id) return;
|
||||
|
||||
async function loadCard() {
|
||||
setIsLoading(true);
|
||||
const result = await getCard(params.id as string);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
try {
|
||||
const result = await getCard(cardId);
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
setError(result.error || '카드를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load card:', err);
|
||||
setError('카드 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
loadCard();
|
||||
}, [params.id, router]);
|
||||
}, [cardId]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/hr/card-management/${params.id}/edit`);
|
||||
// 수정 핸들러
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await updateCard(cardId, data as Partial<CardFormData>);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteCard(cardId);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!params.id) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
const result = await deleteCard(params.id as string);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('카드가 삭제되었습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !card) {
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
// 에러 상태
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardDetail
|
||||
card={card}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{card.cardName}" 카드를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 카드 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
<IntegratedDetailTemplate
|
||||
config={cardConfig}
|
||||
mode={initialMode}
|
||||
initialData={card || undefined}
|
||||
itemId={cardId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal file
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* 카드관리 페이지 로딩 UI (Skeleton)
|
||||
*
|
||||
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
|
||||
*/
|
||||
export default function CardManagementLoading() {
|
||||
return (
|
||||
<div className="p-3 md:p-6 space-y-6">
|
||||
{/* 헤더 Skeleton */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 카드 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 정보 카드 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user