Merge remote-tracking branch 'origin/master'
This commit is contained in:
286
claudedocs/[HOTFIX-2026-01-27] E2E-테스트-수정계획서.md
Normal file
286
claudedocs/[HOTFIX-2026-01-27] E2E-테스트-수정계획서.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# E2E 테스트 기반 프론트엔드 수정 계획서
|
||||||
|
|
||||||
|
**작성일**: 2026-01-27
|
||||||
|
**기준**: sam-hotfix 프로젝트 1월 27일 E2E 테스트 결과
|
||||||
|
**대상**: sam-react-prod (Next.js 프론트엔드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 테스트 결과 요약
|
||||||
|
|
||||||
|
| 구분 | 개수 | 비율 |
|
||||||
|
|------|------|------|
|
||||||
|
| ✅ PASS | 44 | 93.6% |
|
||||||
|
| ⚠️ PARTIAL PASS | 3 | 6.4% |
|
||||||
|
| ❌ FAIL | 0 | 0% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 HIGH 우선순위 수정 항목
|
||||||
|
|
||||||
|
### 1. BUG-CARDTRANS-001: 카드내역 일괄변경 선택 항목 인식 안됨
|
||||||
|
|
||||||
|
**위치**: `/accounting/card-transactions`
|
||||||
|
**컴포넌트**: `src/components/accounting/CardTransactionInquiry/`
|
||||||
|
**심각도**: HIGH
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 테이블 전체선택 체크박스 클릭 → "6개 항목 선택됨" 표시
|
||||||
|
- 계정과목명 드롭다운에서 "경비" 선택
|
||||||
|
- 저장 버튼 클릭
|
||||||
|
- **예상**: "6개의 사용 유형을 경비(으)로 변경하시겠습니까?" 확인 다이얼로그
|
||||||
|
- **실제**: "항목 선택 필요 - 변경할 카드 사용 내역을 먼저 선택해주세요." 오류
|
||||||
|
|
||||||
|
**원인 분석**:
|
||||||
|
- 일괄변경 저장 시 `selectedItems` 상태가 올바르게 전달되지 않음
|
||||||
|
- 또는 저장 핸들러에서 선택 항목 체크 로직 오류
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
```typescript
|
||||||
|
// 확인 필요한 부분
|
||||||
|
1. selectedItems 상태가 일괄변경 컴포넌트에 올바르게 전달되는지
|
||||||
|
2. 저장 핸들러에서 selectedItems.size > 0 체크 로직
|
||||||
|
3. UniversalListPage 또는 IntegratedListTemplateV2의 선택 상태 동기화
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 수정 파일**:
|
||||||
|
- `src/components/accounting/CardTransactionInquiry/index.tsx`
|
||||||
|
- `src/components/templates/UniversalListPage/index.tsx` (선택 상태 관련)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. BUG-BOARD-001: 게시판 글쓰기 폼 미렌더링
|
||||||
|
|
||||||
|
**위치**: `/boards/{board_id}`
|
||||||
|
**컴포넌트**: 동적 게시판 페이지
|
||||||
|
**심각도**: HIGH
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 게시판 목록 페이지에서 "글쓰기" 버튼 클릭
|
||||||
|
- URL이 `?mode=new`로 변경됨
|
||||||
|
- **예상**: 게시글 작성 폼 표시 (제목, 내용 입력 필드)
|
||||||
|
- **실제**: 목록 화면 그대로 유지
|
||||||
|
|
||||||
|
**원인 분석**:
|
||||||
|
- `mode=new` URL 파라미터 감지 후 폼 렌더링 로직 누락
|
||||||
|
- 또는 조건부 렌더링 로직 오류
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
```typescript
|
||||||
|
// 확인 필요한 부분
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
// mode === 'new' 일 때 작성 폼 렌더링
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <BoardWriteForm />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 수정 파일**:
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardId]/page.tsx`
|
||||||
|
- 또는 해당 게시판 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. BUG-BOARD-002: 게시판 수정 폼 미렌더링
|
||||||
|
|
||||||
|
**위치**: `/boards/{board_id}/{post_id}`
|
||||||
|
**컴포넌트**: 게시판 상세 페이지
|
||||||
|
**심각도**: HIGH
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 게시글 상세 페이지에서 "수정" 버튼 클릭
|
||||||
|
- URL이 `?mode=edit`로 변경됨
|
||||||
|
- **예상**: 게시글 편집 폼 표시 (기존 내용 로드)
|
||||||
|
- **실제**: 상세보기 화면 그대로 유지
|
||||||
|
|
||||||
|
**원인 분석**:
|
||||||
|
- `mode=edit` URL 파라미터 감지 후 편집 폼 렌더링 로직 누락
|
||||||
|
- 또는 조건부 렌더링 로직 오류
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
```typescript
|
||||||
|
// 확인 필요한 부분
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
// mode === 'edit' 일 때 편집 폼 렌더링
|
||||||
|
if (mode === 'edit') {
|
||||||
|
return <BoardEditForm postData={postData} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 수정 파일**:
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardId]/[postId]/page.tsx`
|
||||||
|
- 또는 해당 게시판 상세 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 MEDIUM 우선순위 수정 항목
|
||||||
|
|
||||||
|
### 4. BUG-ATTSETTING-001: 자동 출퇴근 설정 저장 안됨
|
||||||
|
|
||||||
|
**위치**: `/settings/attendance-settings`
|
||||||
|
**컴포넌트**: `src/components/settings/AttendanceSettings/` (추정)
|
||||||
|
**심각도**: MEDIUM
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- GPS 출퇴근 활성화 → 부서 선택 → 반경 300M 설정
|
||||||
|
- 자동 출퇴근 활성화
|
||||||
|
- 저장 버튼 클릭
|
||||||
|
- 페이지 새로고침
|
||||||
|
- **예상**: 자동 출퇴근 체크박스 ON 상태 유지
|
||||||
|
- **실제**: 자동 출퇴근 체크박스 OFF로 초기화됨
|
||||||
|
- **참고**: GPS 출퇴근 설정(체크박스, 반경)은 정상 저장됨
|
||||||
|
|
||||||
|
**원인 분석**:
|
||||||
|
- 자동 출퇴근 설정값이 API 호출에 포함되지 않음
|
||||||
|
- 또는 백엔드에서 해당 필드 저장 누락
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
```typescript
|
||||||
|
// 저장 API 호출 시 payload 확인
|
||||||
|
const savePayload = {
|
||||||
|
gpsEnabled: true,
|
||||||
|
gpsRadius: 300,
|
||||||
|
autoPunchEnabled: true, // ← 이 값이 포함되는지 확인
|
||||||
|
...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 수정 파일**:
|
||||||
|
- `src/components/settings/AttendanceSettings/index.tsx`
|
||||||
|
- `src/components/settings/AttendanceSettings/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 LOW 우선순위 수정 항목
|
||||||
|
|
||||||
|
### 5. BUG-ATTSETTING-002: 저장 완료 토스트 미표시
|
||||||
|
|
||||||
|
**위치**: `/settings/attendance-settings`
|
||||||
|
**심각도**: LOW
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
- 저장 버튼 클릭 시 "출퇴근 설정이 저장되었습니다." 토스트 미표시
|
||||||
|
- 콘솔 에러 없음, URL 유지됨
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
```typescript
|
||||||
|
// 저장 성공 후 토스트 추가
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const result = await saveSettings(payload);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('출퇴근 설정이 저장되었습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 작업 체크리스트
|
||||||
|
|
||||||
|
### Phase 1: HIGH 우선순위 (즉시 수정) ✅ 완료
|
||||||
|
|
||||||
|
- [x] **카드내역 일괄변경 버그 수정** (2026-01-27 수정 완료)
|
||||||
|
- [x] CardTransactionInquiry 컴포넌트 분석
|
||||||
|
- [x] selectedItems 상태 흐름 추적
|
||||||
|
- [x] UniversalListPage에 onSelectionChange 콜백 추가
|
||||||
|
- [x] 테스트 검증
|
||||||
|
|
||||||
|
- [x] **게시판 글쓰기 폼 렌더링 수정** (2026-01-27 수정 완료)
|
||||||
|
- [x] 동적 게시판 페이지 구조 분석
|
||||||
|
- [x] mode=new 파라미터 처리 로직 추가
|
||||||
|
- [x] DynamicBoardCreateForm 컴포넌트 분리 및 연결
|
||||||
|
- [x] 테스트 검증
|
||||||
|
|
||||||
|
- [x] **게시판 수정 폼 렌더링 수정** (2026-01-27 수정 완료)
|
||||||
|
- [x] 게시판 상세 페이지 구조 분석
|
||||||
|
- [x] mode=edit 파라미터 처리 로직 추가
|
||||||
|
- [x] DynamicBoardEditForm 컴포넌트 분리 및 연결
|
||||||
|
- [x] 테스트 검증
|
||||||
|
|
||||||
|
### Phase 2: MEDIUM 우선순위 ✅ 완료
|
||||||
|
|
||||||
|
- [x] **자동 출퇴근 설정 저장 버그 수정** (2026-01-27 수정 완료)
|
||||||
|
- [x] 저장 API payload 분석
|
||||||
|
- [x] 백엔드 DB에 use_auto 컬럼 추가 (마이그레이션)
|
||||||
|
- [x] 백엔드 Model/Request 수정
|
||||||
|
- [x] 프론트엔드 API 연동 수정
|
||||||
|
- [x] 테스트 검증
|
||||||
|
|
||||||
|
### Phase 3: LOW 우선순위 ✅ 완료
|
||||||
|
|
||||||
|
- [x] **저장 완료 토스트 추가** (이미 구현되어 있음)
|
||||||
|
- [x] 저장 핸들러에 toast.success 이미 존재 (index.tsx:142)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 추가 확인 필요 사항
|
||||||
|
|
||||||
|
### 백엔드 이슈 (프론트 수정 범위 외)
|
||||||
|
|
||||||
|
| 이슈 | 위치 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| reference-box 500 에러 | `/api/v1/boards/reference` | 백엔드 확인 필요 |
|
||||||
|
|
||||||
|
### 기획 변경 사항
|
||||||
|
|
||||||
|
| 항목 | 변경 내용 | 조치 |
|
||||||
|
|------|----------|------|
|
||||||
|
| payment-history | 구독관리 페이지로 통합됨 | 테스트 시나리오 삭제/수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 참고: 이미 수정된 항목
|
||||||
|
|
||||||
|
### bank-transactions key 중복 오류 (2026-01-27 수정 완료)
|
||||||
|
|
||||||
|
**수정 내용**: 입금/출금 통합 목록에서 React key 중복 오류
|
||||||
|
**수정 파일**: `src/components/accounting/BankTransactionInquiry/actions.ts`
|
||||||
|
**수정 방법**: ID 생성 시 거래 유형을 접두어로 추가
|
||||||
|
```typescript
|
||||||
|
// 기존: id: String(item.id)
|
||||||
|
// 수정: id: `${item.type}-${item.id}`
|
||||||
|
```
|
||||||
|
|
||||||
|
### BUG-CARDTRANS-001: 카드내역 일괄변경 선택 인식 (2026-01-27 수정 완료)
|
||||||
|
|
||||||
|
**수정 내용**: UniversalListPage에서 선택 상태 변경 시 외부 콜백 호출
|
||||||
|
**수정 파일**:
|
||||||
|
- `src/components/templates/UniversalListPage/types.ts` - onSelectionChange 타입 추가
|
||||||
|
- `src/components/templates/UniversalListPage/index.tsx` - useEffect로 콜백 호출 추가
|
||||||
|
|
||||||
|
### BUG-BOARD-001/002: 게시판 글쓰기/수정 폼 미렌더링 (2026-01-27 수정 완료)
|
||||||
|
|
||||||
|
**수정 내용**: mode=new/edit URL 파라미터 처리 로직 추가
|
||||||
|
**신규 컴포넌트**:
|
||||||
|
- `src/components/board/DynamicBoard/DynamicBoardCreateForm.tsx` - 글쓰기 폼
|
||||||
|
- `src/components/board/DynamicBoard/DynamicBoardEditForm.tsx` - 수정 폼
|
||||||
|
|
||||||
|
**수정 파일**:
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardCode]/page.tsx` - mode=new 처리
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx` - mode=edit 처리
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx` - 컴포넌트 재사용
|
||||||
|
- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx` - 컴포넌트 재사용
|
||||||
|
|
||||||
|
### BUG-ATTSETTING-001: 자동 출퇴근 설정 저장 안됨 (2026-01-27 수정 완료)
|
||||||
|
|
||||||
|
**원인**: 백엔드 DB에 use_auto 컬럼이 없었음
|
||||||
|
|
||||||
|
**백엔드 수정 (sam-api)**:
|
||||||
|
- `database/migrations/2026_01_27_144110_add_use_auto_to_attendance_settings.php` - 마이그레이션 생성
|
||||||
|
- `app/Models/Tenants/AttendanceSetting.php` - fillable, casts에 use_auto 추가
|
||||||
|
- `app/Http/Requests/V1/WorkSetting/UpdateAttendanceSettingRequest.php` - validation 규칙 추가
|
||||||
|
|
||||||
|
**프론트엔드 수정 (sam-react-prod)**:
|
||||||
|
- `src/components/settings/AttendanceSettingsManagement/actions.ts` - API 타입 및 transform 함수 수정
|
||||||
|
- `src/components/settings/AttendanceSettingsManagement/index.tsx` - 로드/저장 시 useAuto 필드 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성자**: Claude Code
|
||||||
|
**상태**: 전체 완료 ✅
|
||||||
|
**백엔드 배포 필요**: sam-api 프로젝트에서 `php artisan migrate` 실행 필요
|
||||||
@@ -2,248 +2,16 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 게시판 수정 페이지
|
* 동적 게시판 수정 페이지
|
||||||
|
* DynamicBoardEditForm 컴포넌트를 사용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useParams } from 'next/navigation';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { DynamicBoardEditForm } from '@/components/board/DynamicBoard/DynamicBoardEditForm';
|
||||||
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
|
||||||
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { getDynamicBoardPost, updateDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
|
||||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
|
||||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
|
||||||
|
|
||||||
interface BoardPost {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
authorId: string;
|
|
||||||
authorName: string;
|
|
||||||
status: string;
|
|
||||||
views: number;
|
|
||||||
isNotice: boolean;
|
|
||||||
isSecret: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 데이터 → 프론트엔드 타입 변환
|
|
||||||
function transformApiToPost(apiData: PostApiData): BoardPost {
|
|
||||||
return {
|
|
||||||
id: String(apiData.id),
|
|
||||||
title: apiData.title,
|
|
||||||
content: apiData.content,
|
|
||||||
authorId: String(apiData.user_id),
|
|
||||||
authorName: apiData.author?.name || '회원',
|
|
||||||
status: apiData.status,
|
|
||||||
views: apiData.views,
|
|
||||||
isNotice: apiData.is_notice,
|
|
||||||
isSecret: apiData.is_secret,
|
|
||||||
createdAt: apiData.created_at,
|
|
||||||
updatedAt: apiData.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DynamicBoardEditPage() {
|
export default function DynamicBoardEditPage() {
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const boardCode = params.boardCode as string;
|
const boardCode = params.boardCode as string;
|
||||||
const postId = params.postId as string;
|
const postId = params.postId as string;
|
||||||
|
|
||||||
// 게시판 정보
|
return <DynamicBoardEditForm boardCode={boardCode} postId={postId} />;
|
||||||
const [boardName, setBoardName] = useState<string>('게시판');
|
|
||||||
|
|
||||||
// 원본 게시글
|
|
||||||
const [post, setPost] = useState<BoardPost | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 폼 상태
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [isSecret, setIsSecret] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 게시판 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchBoardInfo() {
|
|
||||||
const result = await getBoardByCode(boardCode);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setBoardName(result.data.boardName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchBoardInfo();
|
|
||||||
}, [boardCode]);
|
|
||||||
|
|
||||||
// 게시글 로드
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchPost() {
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadError(null);
|
|
||||||
|
|
||||||
const result = await getDynamicBoardPost(boardCode, postId);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const postData = transformApiToPost(result.data);
|
|
||||||
setPost(postData);
|
|
||||||
setTitle(postData.title);
|
|
||||||
setContent(postData.content);
|
|
||||||
setIsSecret(postData.isSecret);
|
|
||||||
} else {
|
|
||||||
setLoadError(result.error || '게시글을 찾을 수 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPost();
|
|
||||||
}, [boardCode, postId]);
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!title.trim()) {
|
|
||||||
setError('제목을 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content.trim()) {
|
|
||||||
setError('내용을 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await updateDynamicBoardPost(boardCode, postId, {
|
|
||||||
title: title.trim(),
|
|
||||||
content: content.trim(),
|
|
||||||
is_secret: isSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
router.push(`/ko/boards/${boardCode}/${postId}`);
|
|
||||||
} else {
|
|
||||||
setError(result.error || '게시글 수정에 실패했습니다.');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 취소
|
|
||||||
const handleCancel = () => {
|
|
||||||
router.push(`/ko/boards/${boardCode}/${postId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 로딩 상태
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<DetailPageSkeleton />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로드 에러
|
|
||||||
if (loadError || !post) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
|
||||||
<p className="text-muted-foreground">{loadError || '게시글을 찾을 수 없습니다.'}</p>
|
|
||||||
<Button variant="outline" onClick={() => router.push(`/ko/boards/${boardCode}`)}>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
목록으로
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader
|
|
||||||
title={boardName}
|
|
||||||
description="게시글 수정"
|
|
||||||
icon={MessageSquare}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">게시글 수정</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">제목 *</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="제목을 입력하세요"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 내용 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="content">내용 *</Label>
|
|
||||||
<Textarea
|
|
||||||
id="content"
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
placeholder="내용을 입력하세요"
|
|
||||||
rows={15}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 비밀글 설정 */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isSecret"
|
|
||||||
checked={isSecret}
|
|
||||||
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
|
||||||
비밀글로 등록
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 버튼 영역 */}
|
|
||||||
<div className="mt-6 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{isSubmitting ? '저장 중...' : '저장'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 게시판 상세 페이지
|
* 동적 게시판 상세 페이지
|
||||||
|
* mode=edit인 경우 수정 폼 렌더링
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { DynamicBoardEditForm } from '@/components/board/DynamicBoard/DynamicBoardEditForm';
|
||||||
|
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||||
|
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ArrowLeft, Pencil, Trash2, MessageSquare, Eye } from 'lucide-react';
|
import { ArrowLeft, Pencil, Trash2, MessageSquare, Eye } from 'lucide-react';
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
@@ -68,11 +71,34 @@ function transformApiToPost(apiData: PostApiData): BoardPost {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suspense로 감싸는 wrapper 컴포넌트
|
||||||
export default function DynamicBoardDetailPage() {
|
export default function DynamicBoardDetailPage() {
|
||||||
const router = useRouter();
|
return (
|
||||||
|
<Suspense fallback={<DetailPageSkeleton />}>
|
||||||
|
<DetailModeRouter />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode에 따라 다른 컴포넌트 렌더링 (hooks rule 준수)
|
||||||
|
function DetailModeRouter() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const boardCode = params.boardCode as string;
|
const boardCode = params.boardCode as string;
|
||||||
const postId = params.postId as string;
|
const postId = params.postId as string;
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
// mode=edit인 경우 수정 폼 렌더링
|
||||||
|
if (mode === 'edit') {
|
||||||
|
return <DynamicBoardEditForm boardCode={boardCode} postId={postId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DynamicBoardDetailContent boardCode={boardCode} postId={postId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 상세 컴포넌트 (자체 hooks 사용)
|
||||||
|
function DynamicBoardDetailContent({ boardCode, postId }: { boardCode: string; postId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 게시판 정보
|
// 게시판 정보
|
||||||
const [boardName, setBoardName] = useState<string>('게시판');
|
const [boardName, setBoardName] = useState<string>('게시판');
|
||||||
|
|||||||
@@ -2,161 +2,15 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 게시판 등록 페이지
|
* 동적 게시판 등록 페이지
|
||||||
|
* DynamicBoardCreateForm 컴포넌트를 사용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useParams } from 'next/navigation';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { DynamicBoardCreateForm } from '@/components/board/DynamicBoard/DynamicBoardCreateForm';
|
||||||
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { createDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
|
||||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
|
||||||
|
|
||||||
export default function DynamicBoardCreatePage() {
|
export default function DynamicBoardCreatePage() {
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const boardCode = params.boardCode as string;
|
const boardCode = params.boardCode as string;
|
||||||
|
|
||||||
// 게시판 정보
|
return <DynamicBoardCreateForm boardCode={boardCode} />;
|
||||||
const [boardName, setBoardName] = useState<string>('게시판');
|
|
||||||
|
|
||||||
// 폼 상태
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [isSecret, setIsSecret] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 게시판 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchBoardInfo() {
|
|
||||||
const result = await getBoardByCode(boardCode);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setBoardName(result.data.boardName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchBoardInfo();
|
|
||||||
}, [boardCode]);
|
|
||||||
|
|
||||||
// 폼 제출
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!title.trim()) {
|
|
||||||
setError('제목을 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content.trim()) {
|
|
||||||
setError('내용을 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await createDynamicBoardPost(boardCode, {
|
|
||||||
title: title.trim(),
|
|
||||||
content: content.trim(),
|
|
||||||
is_secret: isSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
router.push(`/ko/boards/${boardCode}/${result.data.id}`);
|
|
||||||
} else {
|
|
||||||
setError(result.error || '게시글 등록에 실패했습니다.');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 취소
|
|
||||||
const handleCancel = () => {
|
|
||||||
router.push(`/ko/boards/${boardCode}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader
|
|
||||||
title={boardName}
|
|
||||||
description="새 게시글 등록"
|
|
||||||
icon={MessageSquare}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">게시글 작성</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title">제목 *</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="제목을 입력하세요"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 내용 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="content">내용 *</Label>
|
|
||||||
<Textarea
|
|
||||||
id="content"
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
placeholder="내용을 입력하세요"
|
|
||||||
rows={15}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 비밀글 설정 */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isSecret"
|
|
||||||
checked={isSecret}
|
|
||||||
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
|
||||||
비밀글로 등록
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 버튼 영역 */}
|
|
||||||
<div className="mt-6 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{isSubmitting ? '등록 중...' : '등록'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
/**
|
/**
|
||||||
* 동적 게시판 목록 페이지
|
* 동적 게시판 목록 페이지
|
||||||
* boardCode에 따라 동적으로 게시판 데이터를 로드
|
* boardCode에 따라 동적으로 게시판 데이터를 로드
|
||||||
|
* mode=new인 경우 글쓰기 폼 렌더링
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect, Suspense } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { DynamicBoardCreateForm } from '@/components/board/DynamicBoard/DynamicBoardCreateForm';
|
||||||
import { MessageSquare, Plus } from 'lucide-react';
|
import { MessageSquare, Plus } from 'lucide-react';
|
||||||
|
import { ListPageSkeleton } from '@/components/ui/skeleton';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { TableCell, TableRow } from '@/components/ui/table';
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -73,10 +76,33 @@ function transformApiToPost(apiData: PostApiData): BoardPost {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suspense로 감싸는 wrapper 컴포넌트
|
||||||
export default function DynamicBoardListPage() {
|
export default function DynamicBoardListPage() {
|
||||||
const router = useRouter();
|
return (
|
||||||
|
<Suspense fallback={<ListPageSkeleton />}>
|
||||||
|
<ModeRouter />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode에 따라 다른 컴포넌트 렌더링 (hooks rule 준수)
|
||||||
|
function ModeRouter() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const boardCode = params.boardCode as string;
|
const boardCode = params.boardCode as string;
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
// mode=new인 경우 글쓰기 폼 렌더링
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <DynamicBoardCreateForm boardCode={boardCode} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DynamicBoardListContent boardCode={boardCode} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 목록 컴포넌트 (자체 hooks 사용)
|
||||||
|
function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 게시판 정보
|
// 게시판 정보
|
||||||
const [boardName, setBoardName] = useState<string>('게시판');
|
const [boardName, setBoardName] = useState<string>('게시판');
|
||||||
|
|||||||
165
src/components/board/DynamicBoard/DynamicBoardCreateForm.tsx
Normal file
165
src/components/board/DynamicBoard/DynamicBoardCreateForm.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 게시판 등록 폼 컴포넌트
|
||||||
|
* - mode=new 또는 /create 페이지에서 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { createDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
||||||
|
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||||
|
|
||||||
|
interface DynamicBoardCreateFormProps {
|
||||||
|
boardCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicBoardCreateForm({ boardCode }: DynamicBoardCreateFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 게시판 정보
|
||||||
|
const [boardName, setBoardName] = useState<string>('게시판');
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isSecret, setIsSecret] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 게시판 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBoardInfo() {
|
||||||
|
const result = await getBoardByCode(boardCode);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBoardName(result.data.boardName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBoardInfo();
|
||||||
|
}, [boardCode]);
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('제목을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
setError('내용을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await createDynamicBoardPost(boardCode, {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
is_secret: isSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
router.push(`/ko/boards/${boardCode}/${result.data.id}`);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '게시글 등록에 실패했습니다.');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push(`/ko/boards/${boardCode}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={boardName}
|
||||||
|
description="새 게시글 등록"
|
||||||
|
icon={MessageSquare}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">게시글 작성</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">제목 *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력하세요"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">내용 *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="내용을 입력하세요"
|
||||||
|
rows={15}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀글 설정 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isSecret"
|
||||||
|
checked={isSecret}
|
||||||
|
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
||||||
|
비밀글로 등록
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{isSubmitting ? '등록 중...' : '등록'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/components/board/DynamicBoard/DynamicBoardEditForm.tsx
Normal file
252
src/components/board/DynamicBoard/DynamicBoardEditForm.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 게시판 수정 폼 컴포넌트
|
||||||
|
* - mode=edit 또는 /edit 페이지에서 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
||||||
|
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { getDynamicBoardPost, updateDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
||||||
|
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||||
|
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||||
|
|
||||||
|
interface BoardPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
status: string;
|
||||||
|
views: number;
|
||||||
|
isNotice: boolean;
|
||||||
|
isSecret: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 데이터 → 프론트엔드 타입 변환
|
||||||
|
function transformApiToPost(apiData: PostApiData): BoardPost {
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
title: apiData.title,
|
||||||
|
content: apiData.content,
|
||||||
|
authorId: String(apiData.user_id),
|
||||||
|
authorName: apiData.author?.name || '회원',
|
||||||
|
status: apiData.status,
|
||||||
|
views: apiData.views,
|
||||||
|
isNotice: apiData.is_notice,
|
||||||
|
isSecret: apiData.is_secret,
|
||||||
|
createdAt: apiData.created_at,
|
||||||
|
updatedAt: apiData.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DynamicBoardEditFormProps {
|
||||||
|
boardCode: string;
|
||||||
|
postId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicBoardEditForm({ boardCode, postId }: DynamicBoardEditFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 게시판 정보
|
||||||
|
const [boardName, setBoardName] = useState<string>('게시판');
|
||||||
|
|
||||||
|
// 원본 게시글
|
||||||
|
const [post, setPost] = useState<BoardPost | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isSecret, setIsSecret] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 게시판 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBoardInfo() {
|
||||||
|
const result = await getBoardByCode(boardCode);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBoardName(result.data.boardName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBoardInfo();
|
||||||
|
}, [boardCode]);
|
||||||
|
|
||||||
|
// 게시글 로드
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPost() {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
|
const result = await getDynamicBoardPost(boardCode, postId);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const postData = transformApiToPost(result.data);
|
||||||
|
setPost(postData);
|
||||||
|
setTitle(postData.title);
|
||||||
|
setContent(postData.content);
|
||||||
|
setIsSecret(postData.isSecret);
|
||||||
|
} else {
|
||||||
|
setLoadError(result.error || '게시글을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPost();
|
||||||
|
}, [boardCode, postId]);
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('제목을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.trim()) {
|
||||||
|
setError('내용을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await updateDynamicBoardPost(boardCode, postId, {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
is_secret: isSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
router.push(`/ko/boards/${boardCode}/${postId}`);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '게시글 수정에 실패했습니다.');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push(`/ko/boards/${boardCode}/${postId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<DetailPageSkeleton />
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로드 에러
|
||||||
|
if (loadError || !post) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||||
|
<p className="text-muted-foreground">{loadError || '게시글을 찾을 수 없습니다.'}</p>
|
||||||
|
<Button variant="outline" onClick={() => router.push(`/ko/boards/${boardCode}`)}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
목록으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={boardName}
|
||||||
|
description="게시글 수정"
|
||||||
|
icon={MessageSquare}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">게시글 수정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">제목 *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="제목을 입력하세요"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">내용 *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="내용을 입력하세요"
|
||||||
|
rows={15}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀글 설정 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isSecret"
|
||||||
|
checked={isSecret}
|
||||||
|
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
||||||
|
비밀글로 등록
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{isSubmitting ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface ApiAttendanceSetting {
|
|||||||
id: number;
|
id: number;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
use_gps: boolean;
|
use_gps: boolean;
|
||||||
|
use_auto: boolean;
|
||||||
allowed_radius: number;
|
allowed_radius: number;
|
||||||
hq_address: string | null;
|
hq_address: string | null;
|
||||||
hq_latitude: number | null;
|
hq_latitude: number | null;
|
||||||
@@ -21,9 +22,10 @@ interface ApiAttendanceSetting {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// React 폼 데이터 타입 (API 지원 필드만)
|
// React 폼 데이터 타입
|
||||||
export interface AttendanceSettingFormData {
|
export interface AttendanceSettingFormData {
|
||||||
useGps: boolean;
|
useGps: boolean;
|
||||||
|
useAuto: boolean;
|
||||||
allowedRadius: number;
|
allowedRadius: number;
|
||||||
hqAddress: string | null;
|
hqAddress: string | null;
|
||||||
hqLatitude: number | null;
|
hqLatitude: number | null;
|
||||||
@@ -60,6 +62,7 @@ interface ApiResponse<T> {
|
|||||||
function transformFromApi(data: ApiAttendanceSetting): AttendanceSettingFormData {
|
function transformFromApi(data: ApiAttendanceSetting): AttendanceSettingFormData {
|
||||||
return {
|
return {
|
||||||
useGps: data.use_gps,
|
useGps: data.use_gps,
|
||||||
|
useAuto: data.use_auto,
|
||||||
allowedRadius: data.allowed_radius,
|
allowedRadius: data.allowed_radius,
|
||||||
hqAddress: data.hq_address,
|
hqAddress: data.hq_address,
|
||||||
hqLatitude: data.hq_latitude,
|
hqLatitude: data.hq_latitude,
|
||||||
@@ -74,6 +77,7 @@ function transformToApi(data: Partial<AttendanceSettingFormData>): Record<string
|
|||||||
const apiData: Record<string, unknown> = {};
|
const apiData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (data.useGps !== undefined) apiData.use_gps = data.useGps;
|
if (data.useGps !== undefined) apiData.use_gps = data.useGps;
|
||||||
|
if (data.useAuto !== undefined) apiData.use_auto = data.useAuto;
|
||||||
if (data.allowedRadius !== undefined) apiData.allowed_radius = data.allowedRadius;
|
if (data.allowedRadius !== undefined) apiData.allowed_radius = data.allowedRadius;
|
||||||
if (data.hqAddress !== undefined) apiData.hq_address = data.hqAddress;
|
if (data.hqAddress !== undefined) apiData.hq_address = data.hqAddress;
|
||||||
if (data.hqLatitude !== undefined) apiData.hq_latitude = data.hqLatitude;
|
if (data.hqLatitude !== undefined) apiData.hq_latitude = data.hqLatitude;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function AttendanceSettingsManagement() {
|
|||||||
setSettings(prev => ({
|
setSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
gpsEnabled: settingResult.data!.useGps,
|
gpsEnabled: settingResult.data!.useGps,
|
||||||
|
autoEnabled: settingResult.data!.useAuto,
|
||||||
allowedRadius: settingResult.data!.allowedRadius as AllowedRadius,
|
allowedRadius: settingResult.data!.allowedRadius as AllowedRadius,
|
||||||
}));
|
}));
|
||||||
} else if (settingResult.error) {
|
} else if (settingResult.error) {
|
||||||
@@ -103,7 +104,7 @@ export function AttendanceSettingsManagement() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자동 출퇴근 사용 토글 (UI 전용 - API 미지원)
|
// 자동 출퇴근 사용 토글
|
||||||
const handleAutoToggle = (checked: boolean) => {
|
const handleAutoToggle = (checked: boolean) => {
|
||||||
setSettings(prev => ({
|
setSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -132,9 +133,9 @@ export function AttendanceSettingsManagement() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// API 지원 필드만 전송
|
|
||||||
const result = await updateAttendanceSetting({
|
const result = await updateAttendanceSetting({
|
||||||
useGps: settings.gpsEnabled,
|
useGps: settings.gpsEnabled,
|
||||||
|
useAuto: settings.autoEnabled,
|
||||||
allowedRadius: settings.allowedRadius,
|
allowedRadius: settings.allowedRadius,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ export function UniversalListPage<T>({
|
|||||||
}
|
}
|
||||||
}, [config.tabs]);
|
}, [config.tabs]);
|
||||||
|
|
||||||
|
// 선택 항목 변경 시 외부 콜백 호출
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.onSelectionChange && !externalSelection) {
|
||||||
|
config.onSelectionChange(selectedItems);
|
||||||
|
}
|
||||||
|
}, [selectedItems, config.onSelectionChange, externalSelection]);
|
||||||
|
|
||||||
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
||||||
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
|
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ export interface UniversalListConfig<T> {
|
|||||||
tabsContent?: ReactNode;
|
tabsContent?: ReactNode;
|
||||||
/** 추가 필터 (Select, DatePicker 등) */
|
/** 추가 필터 (Select, DatePicker 등) */
|
||||||
extraFilters?: ReactNode;
|
extraFilters?: ReactNode;
|
||||||
|
/** 선택 항목 변경 콜백 (외부에서 선택 상태 동기화 필요 시) */
|
||||||
|
onSelectionChange?: (selectedItems: Set<string>) => void;
|
||||||
|
|
||||||
// ===== 커스텀 다이얼로그 슬롯 =====
|
// ===== 커스텀 다이얼로그 슬롯 =====
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user