feat(WEB): 동적 게시판, 파트너 관리, 공지 팝업 모달 추가
- 동적 게시판 시스템 구현 (/boards/[boardCode]) - 파트너 관리 페이지 및 폼 추가 - 공지 팝업 모달 컴포넌트 (NoticePopupModal) - localStorage 기반 1일간 숨김 기능 - 테스트 페이지 (/test/popup) - IntegratedListTemplateV2 개선 - 기타 버그 수정 및 타입 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ Last Updated: 2025-12-30
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **거래처 관리** | `/ko/juil/project/bidding/partners` | ✅ 완료 |
|
||||
| **현장설명회관리** | `/ko/juil/project/bidding/site-briefings` | ✅ 완료 |
|
||||
| **견적관리** | `/ko/juil/project/bidding/estimates` | 🆕 NEW |
|
||||
|
||||
## 공사 관리 (Construction)
|
||||
### 인수인계 / 실측 / 발주 / 시공
|
||||
|
||||
@@ -214,7 +214,8 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[REF] juil-project-structure.md` | 🔴 **NEW** - 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) |
|
||||
| `[NEXT-2025-12-30] partner-management-session-context.md` | ⭐ **세션 체크포인트** - 거래처 관리 리스트 완료, 등록/상세/수정 예정 |
|
||||
| `[REF] juil-project-structure.md` | 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) |
|
||||
|
||||
**프로젝트 정보**:
|
||||
- 업체: 주일 (공사/건설)
|
||||
|
||||
120
claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md
Normal file
120
claudedocs/board/[IMPL-2025-12-30] dynamic-board-creation.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 게시판 동적 생성 구현
|
||||
|
||||
> 작성일: 2025-12-30
|
||||
> 상태: 완료
|
||||
|
||||
## 개요
|
||||
|
||||
게시판 관리에서 게시판을 등록하면 고객센터 메뉴에 자동으로 추가되고,
|
||||
해당 게시판 페이지가 동적으로 렌더링되도록 구현합니다.
|
||||
|
||||
---
|
||||
|
||||
## 작업 목록
|
||||
|
||||
### Phase 1: 게시판 관리 폼 수정
|
||||
|
||||
- [x] 1.1 대상 옵션에 "권한" 추가
|
||||
- 현재: 전사, 부서
|
||||
- 변경: 전사, 부서, **권한**
|
||||
- 파일: `src/components/board/BoardManagement/types.ts`
|
||||
- [x] 1.2 권한 선택 시 다중 선택 체크박스 표시
|
||||
- 파일: `src/components/board/BoardManagement/BoardForm.tsx`
|
||||
- MOCK_PERMISSIONS: 관리자, 매니저, 직원, 게스트
|
||||
- [x] 1.3 API 요청 데이터에 권한 정보 포함
|
||||
- 파일: `src/components/board/BoardManagement/actions.ts`
|
||||
- transformFrontendToApi: permissions → extra_settings.permissions
|
||||
|
||||
### Phase 2: 메뉴 즉시 갱신
|
||||
|
||||
- [x] 2.1 게시판 등록 성공 후 `forceRefreshMenus()` 호출
|
||||
- 파일: `src/app/[locale]/(protected)/board/board-management/new/page.tsx`
|
||||
- [x] 2.2 게시판 수정 성공 후 `forceRefreshMenus()` 호출
|
||||
- 파일: `src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx`
|
||||
|
||||
### Phase 3: 동적 게시판 라우트 생성
|
||||
|
||||
- [x] 3.1 `/customer-center/[boardCode]/page.tsx` - 리스트
|
||||
- [x] 3.2 `/customer-center/[boardCode]/[postId]/page.tsx` - 상세
|
||||
- [x] 3.3 `/customer-center/[boardCode]/create/page.tsx` - 등록
|
||||
- [x] 3.4 `/customer-center/[boardCode]/[postId]/edit/page.tsx` - 수정
|
||||
|
||||
### Phase 4: 테스트 및 검증
|
||||
|
||||
- [ ] 4.1 게시판 등록 → 메뉴 자동 추가 확인
|
||||
- [ ] 4.2 동적 게시판 리스트/상세/등록/수정 동작 확인
|
||||
- [ ] 4.3 권한별 접근 제어 확인
|
||||
|
||||
---
|
||||
|
||||
## 기술 명세
|
||||
|
||||
### 대상 타입
|
||||
|
||||
| 대상 | 옆 셀렉트박스 | API 필드 |
|
||||
|------|---------------|----------|
|
||||
| 전사 | 없음 | `target: 'all'` |
|
||||
| 부서 | 부서 단일 선택 | `target: 'department', target_id: number` |
|
||||
| 권한 | 권한 다중 선택 (체크박스) | `target: 'permission', permissions: string[]` |
|
||||
|
||||
### 게시판 타입
|
||||
|
||||
- **기본 타입**: 1:1문의 형태 (댓글 사용 가능)
|
||||
- **참고 페이지**: `/customer-center/qna`
|
||||
|
||||
### 메뉴 갱신 플로우
|
||||
|
||||
```
|
||||
게시판 등록 API 호출 (POST /api/v1/boards)
|
||||
↓
|
||||
백엔드: 게시판 생성 + 메뉴 테이블에 추가
|
||||
↓
|
||||
프론트: 등록 성공 응답 받음
|
||||
↓
|
||||
프론트: forceRefreshMenus() 호출
|
||||
↓
|
||||
사이드바 메뉴 즉시 업데이트
|
||||
```
|
||||
|
||||
### 동적 게시판 URL 구조
|
||||
|
||||
```
|
||||
/boards/[boardCode] → 목록
|
||||
/boards/[boardCode]/create → 등록
|
||||
/boards/[boardCode]/[postId] → 상세
|
||||
/boards/[boardCode]/[postId]/edit → 수정
|
||||
```
|
||||
|
||||
> **URL 변경 이력 (2025-12-30)**
|
||||
> - 변경 전: `/customer-center/[boardCode]`
|
||||
> - 변경 후: `/boards/[boardCode]`
|
||||
> - 사유: 백엔드 메뉴 API path 규칙에 맞춤 (`/boards/free`, `/boards/board_xxx`)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 수정된 파일
|
||||
- `src/components/board/BoardManagement/types.ts` - BoardTarget에 'permission' 추가
|
||||
- `src/components/board/BoardManagement/BoardForm.tsx` - 권한 다중 선택 UI 추가
|
||||
- `src/components/board/BoardManagement/actions.ts` - permissions 변환 로직
|
||||
- `src/components/customer-center/shared/types.ts` - SystemBoardCode 확장
|
||||
- `src/app/[locale]/(protected)/board/board-management/new/page.tsx` - forceRefreshMenus 호출
|
||||
- `src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx` - forceRefreshMenus 호출
|
||||
|
||||
### 새로 생성된 파일
|
||||
- `src/app/[locale]/(protected)/boards/[boardCode]/page.tsx` - 동적 게시판 목록
|
||||
- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx` - 동적 게시판 상세
|
||||
- `src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx` - 동적 게시판 등록
|
||||
- `src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx` - 동적 게시판 수정
|
||||
|
||||
---
|
||||
|
||||
## 진행 로그
|
||||
|
||||
| 날짜 | 작업 내용 |
|
||||
|------|----------|
|
||||
| 2025-12-30 | 요구사항 정리 및 체크리스트 생성 |
|
||||
| 2025-12-30 | Phase 1~3 구현 완료 |
|
||||
| 2025-12-30 | URL 경로 변경: `/customer-center/[boardCode]` → `/boards/[boardCode]` |
|
||||
| 2025-12-30 | API URL 불일치 해결: `system-boards` → `boards` (DynamicBoard/actions.ts 생성) |
|
||||
@@ -0,0 +1,101 @@
|
||||
# 주일 거래처 관리 세션 컨텍스트
|
||||
|
||||
Last Updated: 2025-12-30
|
||||
|
||||
## 세션 요약 (2025-12-30)
|
||||
|
||||
### 완료된 작업
|
||||
- [x] 거래처 리스트 필터 위치 수정 (테이블 위로 이동)
|
||||
- [x] 거래처 폼 컴포넌트 생성 (PartnerForm.tsx)
|
||||
- [x] 등록 페이지 생성 (/new/page.tsx)
|
||||
- [x] 상세 페이지 생성 (/[id]/page.tsx)
|
||||
- [x] 수정 페이지 생성 (/[id]/edit/page.tsx)
|
||||
- [x] types.ts 확장 (전체 필드 추가)
|
||||
- [x] actions.ts CRUD 함수 추가
|
||||
|
||||
### 다음 세션 TODO
|
||||
- [ ] **회사 정보 + 신용/거래 정보 섹션 합치기** (스크린샷 기준으로 하나의 섹션)
|
||||
- [ ] 실제 API 연동
|
||||
|
||||
### 참고 사항
|
||||
- 스크린샷에서 "회사 정보"와 "신용/거래 정보"가 하나의 Card 섹션으로 되어 있음
|
||||
- 현재 코드는 별도 섹션으로 분리됨 → 합쳐야 함
|
||||
|
||||
---
|
||||
|
||||
## 완료된 작업 (전체)
|
||||
|
||||
### 1. 프로젝트 구조 설정
|
||||
- [x] `claudedocs/juil/` 문서 폴더 생성
|
||||
- [x] `[REF] juil-project-structure.md` 프로젝트 구조 가이드 작성
|
||||
- [x] `_index.md` 문서 맵에 juil 섹션 추가
|
||||
|
||||
### 2. 거래처 관리 리스트 페이지
|
||||
- [x] 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/page.tsx`
|
||||
- [x] 컴포넌트: `src/components/business/juil/partners/PartnerListClient.tsx`
|
||||
- [x] 타입: `src/components/business/juil/partners/types.ts`
|
||||
- [x] 액션: `src/components/business/juil/partners/actions.ts` (목업 데이터)
|
||||
- [x] 인덱스: `src/components/business/juil/partners/index.ts`
|
||||
- [x] 레이아웃 수정: 필터를 테이블 위로 이동, 등록 버튼 상단 배치
|
||||
|
||||
### 3. 거래처 등록/상세/수정 페이지
|
||||
- [x] 폼 컴포넌트: `src/components/business/juil/partners/PartnerForm.tsx`
|
||||
- [x] 등록 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/new/page.tsx`
|
||||
- [x] 상세 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/[id]/page.tsx`
|
||||
- [x] 수정 페이지: `src/app/[locale]/(protected)/juil/project/bidding/partners/[id]/edit/page.tsx`
|
||||
|
||||
### 4. 구현된 기능
|
||||
|
||||
#### 리스트 페이지
|
||||
- 통계 카드 (전체 거래처 / 미등록)
|
||||
- 검색 (거래처명, 번호, 대표자, 담당자)
|
||||
- 탭 필터 (전체 / 신규)
|
||||
- 테이블 위 필터: `총 N건 | 전체 ▾ | 최신순 ▾`
|
||||
- 테이블 컬럼: 체크박스, 번호, 거래처번호, 구분, 거래처명, 대표자, 담당자, 전화번호, 매출 결제일, 악성채권, 작업
|
||||
- 행 선택 시 수정/삭제 버튼 표시
|
||||
- 일괄 삭제 다이얼로그
|
||||
- 페이지네이션
|
||||
- 모바일 카드 뷰
|
||||
|
||||
#### 폼 페이지 (등록/상세/수정 공통)
|
||||
- **기본 정보**: 사업자등록번호, 거래처코드, 거래처명, 대표자명, 거래처유형, 업태, 업종
|
||||
- **연락처 정보**: 주소 (우편번호 찾기 DAUM), 전화번호, 모바일, 팩스, 이메일
|
||||
- **담당자 정보**: 담당자명, 담당자 전화, 시스템 관리자
|
||||
- **회사 정보**: 회사 로고 (BLOB 업로드), 매출 결제일, 신용등급, 거래등급, 세금계산서 이메일
|
||||
- **추가 정보**: 미수금, 연체 (토글), 악성채권 (토글)
|
||||
- **메모**: 추가/삭제 기능
|
||||
- **필요 서류**: 파일 업로드 (드래그 앤 드롭)
|
||||
|
||||
#### 모드별 버튼 분기
|
||||
- **등록**: 취소 | 저장
|
||||
- **수정**: 삭제 | 수정
|
||||
- **상세**: 목록가기 | 수정
|
||||
|
||||
## 테스트 URL
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| 거래처 관리 (리스트) | `/ko/juil/project/bidding/partners` | ✅ 완료 |
|
||||
| 거래처 등록 | `/ko/juil/project/bidding/partners/new` | ✅ 완료 |
|
||||
| 거래처 상세 | `/ko/juil/project/bidding/partners/1` | ✅ 완료 |
|
||||
| 거래처 수정 | `/ko/juil/project/bidding/partners/1/edit` | ✅ 완료 |
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/
|
||||
│ └── project/bidding/partners/
|
||||
│ ├── page.tsx ✅
|
||||
│ ├── new/page.tsx ✅
|
||||
│ └── [id]/
|
||||
│ ├── page.tsx ✅
|
||||
│ └── edit/page.tsx ✅
|
||||
│
|
||||
└── components/business/juil/partners/
|
||||
├── index.ts ✅
|
||||
├── types.ts ✅
|
||||
├── actions.ts ✅ (목업)
|
||||
├── PartnerListClient.tsx ✅
|
||||
└── PartnerForm.tsx ✅ (섹션 수정 필요)
|
||||
```
|
||||
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
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';
|
||||
|
||||
@@ -51,6 +52,8 @@ export default function BoardEditPage() {
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 게시판 수정 성공 시 메뉴 즉시 갱신
|
||||
await forceRefreshMenus();
|
||||
router.push(`/ko/board/board-management/${board.id}`);
|
||||
} else {
|
||||
setError(result.error || '수정에 실패했습니다.');
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
// 게시판 코드 생성 (임시: 타임스탬프 기반)
|
||||
@@ -29,6 +30,8 @@ export default function BoardNewPage() {
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 게시판 생성 성공 시 메뉴 즉시 갱신
|
||||
await forceRefreshMenus();
|
||||
router.push('/ko/board/board-management');
|
||||
} else {
|
||||
setError(result.error || '게시판 생성에 실패했습니다.');
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 수정 페이지
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Save, MessageSquare, Loader2 } 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 { 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() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
const postId = params.postId as string;
|
||||
|
||||
// 게시판 정보
|
||||
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>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 상세 페이지
|
||||
*/
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, Pencil, Trash2, MessageSquare, Eye } 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 { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
getDynamicBoardPost,
|
||||
getDynamicBoardComments,
|
||||
createDynamicBoardComment,
|
||||
updateDynamicBoardComment,
|
||||
deleteDynamicBoardComment,
|
||||
deleteDynamicBoardPost,
|
||||
} from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types';
|
||||
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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
inquiryId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorProfileImage?: string;
|
||||
content: string;
|
||||
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,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DynamicBoardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
const postId = params.postId as string;
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
|
||||
// 게시글 및 댓글
|
||||
const [post, setPost] = useState<BoardPost | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||
|
||||
// 댓글 입력
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||
const [editingContent, setEditingContent] = useState('');
|
||||
|
||||
// 현재 사용자 ID 가져오기
|
||||
useEffect(() => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
setCurrentUserId(String(user.id || ''));
|
||||
} catch {
|
||||
setCurrentUserId('');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 게시판 정보 로드
|
||||
useEffect(() => {
|
||||
async function fetchBoardInfo() {
|
||||
const result = await getBoardByCode(boardCode);
|
||||
if (result.success && result.data) {
|
||||
setBoardName(result.data.boardName);
|
||||
}
|
||||
}
|
||||
fetchBoardInfo();
|
||||
}, [boardCode]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [postResult, commentsResult] = await Promise.all([
|
||||
getDynamicBoardPost(boardCode, postId),
|
||||
getDynamicBoardComments(boardCode, postId),
|
||||
]);
|
||||
|
||||
if (postResult.success && postResult.data) {
|
||||
setPost(transformApiToPost(postResult.data));
|
||||
} else {
|
||||
setError(postResult.error || '게시글을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (commentsResult.success && commentsResult.data) {
|
||||
setComments(commentsResult.data.map(transformApiToComment));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [boardCode, postId]);
|
||||
|
||||
// 댓글 추가
|
||||
const handleAddComment = useCallback(async () => {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
const result = await createDynamicBoardComment(boardCode, postId, newComment);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) => [...prev, transformApiToComment(result.data!)]);
|
||||
setNewComment('');
|
||||
} else {
|
||||
console.error('댓글 등록 실패:', result.error);
|
||||
}
|
||||
}, [boardCode, postId, newComment]);
|
||||
|
||||
// 댓글 수정
|
||||
const handleUpdateComment = useCallback(async (commentId: string) => {
|
||||
if (!editingContent.trim()) return;
|
||||
|
||||
const result = await updateDynamicBoardComment(boardCode, postId, commentId, editingContent);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c))
|
||||
);
|
||||
setEditingCommentId(null);
|
||||
setEditingContent('');
|
||||
} else {
|
||||
console.error('댓글 수정 실패:', result.error);
|
||||
}
|
||||
}, [boardCode, postId, editingContent]);
|
||||
|
||||
// 댓글 삭제
|
||||
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||
const result = await deleteDynamicBoardComment(boardCode, postId, commentId);
|
||||
if (result.success) {
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} else {
|
||||
console.error('댓글 삭제 실패:', result.error);
|
||||
}
|
||||
}, [boardCode, postId]);
|
||||
|
||||
// 게시글 삭제
|
||||
const handleDeletePost = useCallback(async () => {
|
||||
const result = await deleteDynamicBoardPost(boardCode, postId);
|
||||
if (result.success) {
|
||||
router.push(`/ko/boards/${boardCode}`);
|
||||
} else {
|
||||
console.error('게시글 삭제 실패:', result.error);
|
||||
}
|
||||
}, [boardCode, postId, router]);
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/boards/${boardCode}/${postId}/edit`);
|
||||
}, [router, boardCode, postId]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleBack = useCallback(() => {
|
||||
router.push(`/ko/boards/${boardCode}`);
|
||||
}, [router, boardCode]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-muted-foreground">{error || '게시글을 찾을 수 없습니다.'}</p>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthor = currentUserId === post.authorId;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={boardName}
|
||||
description="게시글 상세"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
|
||||
{/* 게시글 상세 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{post.isNotice && (
|
||||
<Badge variant="destructive">공지</Badge>
|
||||
)}
|
||||
<CardTitle className="text-xl">{post.title}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{post.authorName}</span>
|
||||
<span>{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
{post.views}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시글 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 게시글을 삭제하시겠습니까? 삭제된 게시글은 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeletePost}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 댓글 섹션 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
댓글 ({comments.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 댓글 목록 */}
|
||||
{comments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{comment.authorName}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
{currentUserId === comment.authorId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingCommentId(comment.id);
|
||||
setEditingContent(comment.content);
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingCommentId === comment.id ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Textarea
|
||||
value={editingContent}
|
||||
onChange={(e) => setEditingContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditingContent('');
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handleUpdateComment(comment.id)}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
첫 번째 댓글을 작성해보세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 댓글 입력 */}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="댓글을 입력하세요..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAddComment} disabled={!newComment.trim()}>
|
||||
댓글 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="mt-6 flex justify-start">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
162
src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx
Normal file
162
src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } 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';
|
||||
|
||||
export default function DynamicBoardCreatePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
|
||||
// 게시판 정보
|
||||
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>
|
||||
);
|
||||
}
|
||||
398
src/app/[locale]/(protected)/boards/[boardCode]/page.tsx
Normal file
398
src/app/[locale]/(protected)/boards/[boardCode]/page.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 목록 페이지
|
||||
* boardCode에 따라 동적으로 게시판 데이터를 로드
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TableColumn,
|
||||
type PaginationConfig,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// 정렬 옵션
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
];
|
||||
|
||||
// 게시글 상태 옵션
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'published', label: '게시됨' },
|
||||
{ value: 'draft', label: '임시저장' },
|
||||
];
|
||||
|
||||
interface BoardPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
status: string;
|
||||
views: number;
|
||||
isNotice: 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,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DynamicBoardListPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const boardCode = params.boardCode as string;
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
const [boardDescription, setBoardDescription] = useState<string>('');
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 필터 및 검색
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortOption, setSortOption] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// 게시판 정보 로드
|
||||
useEffect(() => {
|
||||
async function fetchBoardInfo() {
|
||||
const result = await getBoardByCode(boardCode);
|
||||
if (result.success && result.data) {
|
||||
setBoardName(result.data.boardName);
|
||||
setBoardDescription(result.data.description || '');
|
||||
}
|
||||
}
|
||||
fetchBoardInfo();
|
||||
}, [boardCode]);
|
||||
|
||||
// 게시글 목록 로드
|
||||
useEffect(() => {
|
||||
async function fetchPosts() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getDynamicBoardPosts(boardCode, { per_page: 100 });
|
||||
|
||||
if (result.success && result.data) {
|
||||
const transformed = result.data.data.map(transformApiToPost);
|
||||
setPosts(transformed);
|
||||
} else {
|
||||
setError(result.error || '게시글 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchPosts();
|
||||
}, [boardCode]);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...posts];
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all') {
|
||||
result = result.filter((item) => item.status === statusFilter);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (startDate && endDate) {
|
||||
result = result.filter((item) => {
|
||||
const itemDate = format(new Date(item.createdAt), 'yyyy-MM-dd');
|
||||
return itemDate >= startDate && itemDate <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchLower) ||
|
||||
item.authorName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortOption === 'latest') {
|
||||
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
} else {
|
||||
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [posts, statusFilter, startDate, endDate, searchValue, sortOption]);
|
||||
|
||||
// 페이지네이션
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredData.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredData, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
||||
|
||||
// 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: BoardPost) => {
|
||||
router.push(`/ko/boards/${boardCode}/${item.id}`);
|
||||
},
|
||||
[router, boardCode]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push(`/ko/boards/${boardCode}/create`);
|
||||
}, [router, boardCode]);
|
||||
|
||||
// 상태 Badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === 'published') {
|
||||
return <Badge variant="secondary" className="bg-green-100 text-green-700">게시됨</Badge>;
|
||||
}
|
||||
if (status === 'draft') {
|
||||
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-700">임시저장</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
};
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: BoardPost, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isNotice && (
|
||||
<Badge variant="destructive" className="text-xs">공지</Badge>
|
||||
)}
|
||||
{item.title}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.authorName}</TableCell>
|
||||
<TableCell className="text-center">{item.views}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{format(new Date(item.createdAt), 'yyyy-MM-dd')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleRowClick, handleToggleSelection]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(
|
||||
item: BoardPost,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
|
||||
{item.isNotice && (
|
||||
<Badge variant="destructive" className="text-xs">공지</Badge>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{item.authorName}</span>
|
||||
<span>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination: PaginationConfig = {
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2
|
||||
title={boardName}
|
||||
description={boardDescription || `${boardName} 게시판입니다.`}
|
||||
icon={MessageSquare}
|
||||
headerActions={
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<Button className="ml-auto" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
글쓰기
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="제목, 작성자로 검색..."
|
||||
tableHeaderActions={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {filteredData.length}건
|
||||
</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={setStatusFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EstimateListClient } from '@/components/business/juil/estimates';
|
||||
|
||||
export default function EstimatesPage() {
|
||||
return <EstimateListClient />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/juil/partners/actions';
|
||||
|
||||
interface PartnerEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="edit"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/juil/partners/actions';
|
||||
|
||||
interface PartnerDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="view"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
|
||||
|
||||
export default function PartnerNewPage() {
|
||||
return <PartnerForm mode="new" />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/juil/site-briefings';
|
||||
|
||||
interface SiteBriefingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="edit"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/juil/site-briefings';
|
||||
|
||||
interface SiteBriefingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="view"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteBriefingForm } from '@/components/business/juil/site-briefings';
|
||||
|
||||
export default function SiteBriefingNewPage() {
|
||||
return <SiteBriefingForm mode="new" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteBriefingListClient } from '@/components/business/juil/site-briefings';
|
||||
|
||||
export default function SiteBriefingsPage() {
|
||||
return <SiteBriefingListClient />;
|
||||
}
|
||||
134
src/app/[locale]/(protected)/test/popup/page.tsx
Normal file
134
src/app/[locale]/(protected)/test/popup/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { NoticePopupModal, isPopupDismissedForToday } from '@/components/common/NoticePopupModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import type { NoticePopupData } from '@/components/common/NoticePopupModal';
|
||||
|
||||
// 테스트용 샘플 팝업 데이터
|
||||
const SAMPLE_POPUPS: NoticePopupData[] = [
|
||||
{
|
||||
id: 'popup-1',
|
||||
title: '휴가 안내',
|
||||
content: '<p>전사 휴가 안내입니다.</p><p>2025년 신정 연휴 기간: 2025.01.01 ~ 2025.01.03</p>',
|
||||
imageUrl: undefined,
|
||||
},
|
||||
{
|
||||
id: 'popup-2',
|
||||
title: '시스템 점검 안내',
|
||||
content: '<p>시스템 점검으로 인해 아래 일시에 서비스 이용이 제한됩니다.</p><ul><li>점검일시: 2025.01.15 00:00 ~ 06:00</li><li>점검내용: 서버 업그레이드</li></ul>',
|
||||
imageUrl: 'https://placehold.co/400x300/e2e8f0/64748b?text=Notice+Image',
|
||||
},
|
||||
{
|
||||
id: 'popup-3',
|
||||
title: '신규 기능 안내',
|
||||
content: '<p>새로운 기능이 추가되었습니다!</p><p>자세한 내용은 공지사항을 확인해주세요.</p>',
|
||||
imageUrl: 'https://placehold.co/400x300/dbeafe/3b82f6?text=New+Feature',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PopupTestPage() {
|
||||
const [selectedPopup, setSelectedPopup] = useState<NoticePopupData | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleOpenPopup = (popup: NoticePopupData, force = false) => {
|
||||
// 숨김 처리된 팝업은 강제로 열지 않는 한 열지 않음
|
||||
if (!force && isPopupDismissedForToday(popup.id)) {
|
||||
return;
|
||||
}
|
||||
setSelectedPopup(popup);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const clearDismissedPopups = () => {
|
||||
SAMPLE_POPUPS.forEach((popup) => {
|
||||
localStorage.removeItem(`popup_dismissed_${popup.id}`);
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 타이틀 */}
|
||||
<h1 className="text-2xl font-bold">팝업 테스트 페이지</h1>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 border">
|
||||
<h2 className="text-lg font-medium mb-2">팝업 모달 테스트</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
아래 버튼을 클릭하여 팝업 모달을 테스트할 수 있습니다.
|
||||
"1일간 이 창을 열지 않음" 체크 후 닫으면 localStorage에 저장됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 팝업 리스트 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{SAMPLE_POPUPS.map((popup) => {
|
||||
const isDismissed = isPopupDismissedForToday(popup.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={popup.id}
|
||||
className="border rounded-lg p-4 bg-background"
|
||||
>
|
||||
<h3 className="font-medium mb-2">{popup.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">
|
||||
{popup.content.replace(/<[^>]*>/g, '')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
isDismissed
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}
|
||||
>
|
||||
{isDismissed ? '오늘 숨김됨' : '표시 가능'}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isDismissed ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenPopup(popup, true)}
|
||||
>
|
||||
강제로 열기
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleOpenPopup(popup)}
|
||||
>
|
||||
팝업 열기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="outline" onClick={clearDismissedPopups}>
|
||||
숨김 설정 초기화 (localStorage 삭제)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 팝업 모달 */}
|
||||
{selectedPopup && (
|
||||
<NoticePopupModal
|
||||
popup={selectedPopup}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ClipboardList, ArrowLeft, Save } from 'lucide-react';
|
||||
import type { Board, BoardFormData, BoardTarget, BoardStatus } from './types';
|
||||
import { BOARD_TARGETS, BOARD_STATUS_LABELS } from './types';
|
||||
@@ -29,6 +30,14 @@ const MOCK_DEPARTMENTS = [
|
||||
{ id: 5, name: '마케팅팀' },
|
||||
];
|
||||
|
||||
// TODO: API에서 권한 목록 가져오기
|
||||
const MOCK_PERMISSIONS = [
|
||||
{ code: 'admin', name: '관리자' },
|
||||
{ code: 'manager', name: '매니저' },
|
||||
{ code: 'staff', name: '직원' },
|
||||
{ code: 'guest', name: '게스트' },
|
||||
];
|
||||
|
||||
interface BoardFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
board?: Board;
|
||||
@@ -56,6 +65,7 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
const [formData, setFormData] = useState<BoardFormData>({
|
||||
target: 'all',
|
||||
targetName: '',
|
||||
permissions: [],
|
||||
boardName: '',
|
||||
status: 'active',
|
||||
});
|
||||
@@ -66,6 +76,7 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
setFormData({
|
||||
target: board.target,
|
||||
targetName: board.targetName || '',
|
||||
permissions: board.permissions || [],
|
||||
boardName: board.boardName,
|
||||
status: board.status,
|
||||
});
|
||||
@@ -89,7 +100,18 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
target: value,
|
||||
targetName: value === 'all' ? '' : prev.targetName,
|
||||
targetName: value === 'department' ? prev.targetName : '',
|
||||
permissions: value === 'permission' ? prev.permissions : [],
|
||||
}));
|
||||
};
|
||||
|
||||
// 권한 체크박스 핸들러
|
||||
const handlePermissionChange = (code: string, checked: boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: checked
|
||||
? [...(prev.permissions || []), code]
|
||||
: (prev.permissions || []).filter(p => p !== code),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -151,6 +173,25 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{formData.target === 'permission' && (
|
||||
<div className="flex-1 flex flex-wrap gap-4 items-center border rounded-md px-3 py-2">
|
||||
{MOCK_PERMISSIONS.map((perm) => (
|
||||
<div key={perm.code} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`perm-${perm.code}`}
|
||||
checked={(formData.permissions || []).includes(perm.code)}
|
||||
onCheckedChange={(checked) => handlePermissionChange(perm.code, checked as boolean)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`perm-${perm.code}`}
|
||||
className="font-normal cursor-pointer text-sm"
|
||||
>
|
||||
{perm.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ interface ApiResponse<T> {
|
||||
function transformApiToFrontend(apiData: BoardApiData): Board {
|
||||
const extraSettings = apiData.extra_settings || {};
|
||||
|
||||
// permissions 추출 (read 권한 기준으로 사용)
|
||||
const permissions = extraSettings.permissions?.read || [];
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
boardCode: apiData.board_code,
|
||||
@@ -35,6 +38,7 @@ function transformApiToFrontend(apiData: BoardApiData): Board {
|
||||
target: extraSettings.target || 'all',
|
||||
targetId: extraSettings.target_id,
|
||||
targetName: extraSettings.target_name,
|
||||
permissions: permissions.length > 0 ? permissions : undefined,
|
||||
boardName: apiData.name,
|
||||
description: apiData.description || undefined,
|
||||
status: apiData.is_active ? 'active' : 'inactive',
|
||||
@@ -50,14 +54,26 @@ function transformApiToFrontend(apiData: BoardApiData): Board {
|
||||
* 프론트엔드 데이터 → API 요청 형식 변환
|
||||
*/
|
||||
function transformFrontendToApi(data: BoardFormData & { boardCode?: string; description?: string }, isUpdate = false): Record<string, unknown> {
|
||||
// extra_settings 구성
|
||||
const extraSettings: Record<string, unknown> = {
|
||||
target: data.target,
|
||||
target_name: data.target === 'department' ? data.targetName : null,
|
||||
};
|
||||
|
||||
// 권한 대상인 경우 permissions 추가
|
||||
if (data.target === 'permission' && data.permissions && data.permissions.length > 0) {
|
||||
extraSettings.permissions = {
|
||||
read: data.permissions,
|
||||
write: data.permissions,
|
||||
manage: data.permissions,
|
||||
};
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
name: data.boardName,
|
||||
description: data.description || null,
|
||||
is_active: data.status === 'active',
|
||||
extra_settings: {
|
||||
target: data.target,
|
||||
target_name: data.target === 'department' ? data.targetName : null,
|
||||
},
|
||||
extra_settings: extraSettings,
|
||||
};
|
||||
|
||||
// 생성 시에만 board_code 전송 (수정 시에는 코드 변경 불가)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 게시판 상태 타입
|
||||
export type BoardStatus = 'active' | 'inactive';
|
||||
|
||||
// 대상 타입 (전사, 부서 등)
|
||||
export type BoardTarget = 'all' | 'department';
|
||||
// 대상 타입 (전사, 부서, 권한)
|
||||
export type BoardTarget = 'all' | 'department' | 'permission';
|
||||
|
||||
// 게시판 타입 (프론트엔드용)
|
||||
export interface Board {
|
||||
@@ -12,6 +12,7 @@ export interface Board {
|
||||
target: BoardTarget;
|
||||
targetId?: number;
|
||||
targetName?: string; // 부서명 (target이 department일 때)
|
||||
permissions?: string[]; // 권한 코드 목록 (target이 permission일 때)
|
||||
boardName: string;
|
||||
description?: string;
|
||||
status: BoardStatus;
|
||||
@@ -69,6 +70,7 @@ export interface BoardApiData {
|
||||
export interface BoardFormData {
|
||||
target: BoardTarget;
|
||||
targetName?: string;
|
||||
permissions?: string[]; // 권한 코드 목록 (target이 permission일 때)
|
||||
boardName: string;
|
||||
status: BoardStatus;
|
||||
}
|
||||
@@ -89,10 +91,12 @@ export const BOARD_STATUS_COLORS: Record<BoardStatus, string> = {
|
||||
export const BOARD_TARGET_LABELS: Record<BoardTarget, string> = {
|
||||
all: '전사',
|
||||
department: '부서',
|
||||
permission: '권한',
|
||||
};
|
||||
|
||||
// 대상 옵션
|
||||
export const BOARD_TARGETS = [
|
||||
{ value: 'all' as BoardTarget, label: '전사' },
|
||||
{ value: 'department' as BoardTarget, label: '부서' },
|
||||
{ value: 'permission' as BoardTarget, label: '권한' },
|
||||
];
|
||||
|
||||
371
src/components/board/DynamicBoard/actions.ts
Normal file
371
src/components/board/DynamicBoard/actions.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 동적 게시판 Server Actions
|
||||
* 일반 게시판 게시글 API 호출 (/api/v1/boards/{code}/posts)
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
PostApiData,
|
||||
PostPaginationResponse,
|
||||
ApiResponse,
|
||||
PostFilters,
|
||||
CommentApiData,
|
||||
CommentsApiResponse,
|
||||
} from '@/components/customer-center/shared/types';
|
||||
|
||||
/**
|
||||
* 게시글 목록 조회
|
||||
*/
|
||||
export async function getDynamicBoardPosts(
|
||||
boardCode: string,
|
||||
filters?: PostFilters
|
||||
): Promise<{ success: boolean; data?: PostPaginationResponse; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.is_notice !== undefined) params.append('is_notice', String(filters.is_notice));
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.per_page) params.append('per_page', String(filters.per_page));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '게시글 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
let result: ApiResponse<PostPaginationResponse>;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch {
|
||||
console.error('[DynamicBoardActions] JSON parse error');
|
||||
return { success: false, error: '서버 응답 형식 오류입니다.' };
|
||||
}
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '게시글 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] getDynamicBoardPosts error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 상세 조회
|
||||
*/
|
||||
export async function getDynamicBoardPost(
|
||||
boardCode: string,
|
||||
postId: number | string
|
||||
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '게시글 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PostApiData> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success || !result.data) {
|
||||
return { success: false, error: result.message || '게시글을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] getDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 등록
|
||||
*/
|
||||
export async function createDynamicBoardPost(
|
||||
boardCode: string,
|
||||
data: {
|
||||
title: string;
|
||||
content: string;
|
||||
is_secret?: boolean;
|
||||
is_notice?: boolean;
|
||||
custom_fields?: Record<string, string>;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '게시글 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '게시글 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] createDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 수정
|
||||
*/
|
||||
export async function updateDynamicBoardPost(
|
||||
boardCode: string,
|
||||
postId: number | string,
|
||||
data: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
is_secret?: boolean;
|
||||
is_notice?: boolean;
|
||||
custom_fields?: Record<string, string>;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '게시글 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '게시글 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] updateDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 삭제
|
||||
*/
|
||||
export async function deleteDynamicBoardPost(
|
||||
boardCode: string,
|
||||
postId: number | string
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '게시글 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '게시글 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] deleteDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 댓글 API =====
|
||||
|
||||
/**
|
||||
* 댓글 목록 조회
|
||||
*/
|
||||
export async function getDynamicBoardComments(
|
||||
boardCode: string,
|
||||
postId: number | string
|
||||
): Promise<{ success: boolean; data?: CommentsApiResponse; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '댓글 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<CommentsApiResponse> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '댓글 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] getDynamicBoardComments error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성
|
||||
*/
|
||||
export async function createDynamicBoardComment(
|
||||
boardCode: string,
|
||||
postId: number | string,
|
||||
content: string
|
||||
): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '댓글 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '댓글 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] createDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 수정
|
||||
*/
|
||||
export async function updateDynamicBoardComment(
|
||||
boardCode: string,
|
||||
postId: number | string,
|
||||
commentId: number | string,
|
||||
content: string
|
||||
): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments/${commentId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '댓글 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '댓글 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] updateDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 삭제
|
||||
*/
|
||||
export async function deleteDynamicBoardComment(
|
||||
boardCode: string,
|
||||
postId: number | string,
|
||||
commentId: number | string
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments/${commentId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '댓글 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '댓글 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[DynamicBoardActions] deleteDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
569
src/components/business/juil/estimates/EstimateListClient.tsx
Normal file
569
src/components/business/juil/estimates/EstimateListClient.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, FileTextIcon, FilePenLine, FileCheck, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Estimate, EstimateStats } from './types';
|
||||
import {
|
||||
ESTIMATE_STATUS_OPTIONS,
|
||||
ESTIMATE_SORT_OPTIONS,
|
||||
STATUS_STYLES,
|
||||
STATUS_LABELS,
|
||||
AWARD_STATUS_LABELS,
|
||||
AWARD_STATUS_STYLES,
|
||||
} from './types';
|
||||
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'estimateCode', label: '견적번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
|
||||
{ key: 'itemCount', label: '계', className: 'w-[60px] text-center' },
|
||||
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'distributionDate', label: '견적배부일', className: 'w-[110px] text-center' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
|
||||
{ key: 'awardStatus', label: '낙찰', className: 'w-[70px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '야사 대림아파트' },
|
||||
{ value: '3', label: '여의 현장아파트' },
|
||||
];
|
||||
|
||||
// 목업 견적자 목록
|
||||
const MOCK_ESTIMATORS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
interface EstimateListClientProps {
|
||||
initialData?: Estimate[];
|
||||
initialStats?: EstimateStats;
|
||||
}
|
||||
|
||||
export default function EstimateListClient({ initialData = [], initialStats }: EstimateListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilter, setPartnerFilter] = useState<string>('all');
|
||||
const [estimatorFilter, setEstimatorFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'drafting' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getEstimateList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getEstimateStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setEstimates(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredEstimates = useMemo(() => {
|
||||
return estimates.filter((estimate) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'drafting' && estimate.status !== 'drafting') return false;
|
||||
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilter !== 'all' && estimate.partnerId !== partnerFilter) return false;
|
||||
|
||||
// 견적자 필터
|
||||
if (estimatorFilter !== 'all' && estimate.estimatorId !== estimatorFilter) return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
estimate.projectName.toLowerCase().includes(search) ||
|
||||
estimate.estimateCode.toLowerCase().includes(search) ||
|
||||
estimate.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [estimates, activeStatTab, partnerFilter, estimatorFilter, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedEstimates = useMemo(() => {
|
||||
const sorted = [...filteredEstimates];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredEstimates, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedEstimates.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedEstimates.slice(start, start + itemsPerPage);
|
||||
}, [sortedEstimates, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((e) => e.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(estimate: Estimate) => {
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimate.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/estimates/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(estimateId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteEstimate(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('견적이 삭제되었습니다.');
|
||||
setEstimates((prev) => prev.filter((e) => e.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteEstimates(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(estimate: Estimate, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(estimate.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={estimate.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(estimate)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(estimate.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{estimate.estimateCode}</TableCell>
|
||||
<TableCell>{estimate.partnerName}</TableCell>
|
||||
<TableCell>{estimate.projectName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.itemCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
|
||||
<TableCell className="text-center">{estimate.distributionDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={AWARD_STATUS_STYLES[estimate.awardStatus]}>
|
||||
{AWARD_STATUS_LABELS[estimate.awardStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[estimate.status]}>
|
||||
{STATUS_LABELS[estimate.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, estimate.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, estimate.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(estimate: Estimate, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={estimate.projectName}
|
||||
subtitle={estimate.estimateCode}
|
||||
badge={STATUS_LABELS[estimate.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(estimate)}
|
||||
details={[
|
||||
{ label: '거래처', value: estimate.partnerName },
|
||||
{ label: '견적자', value: estimate.estimatorName },
|
||||
{ label: '견적금액', value: `${formatAmount(estimate.estimateAmount)}원` },
|
||||
{ label: '입찰일', value: estimate.bidDate || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (등록 버튼 + 날짜 필터)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
견적 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileTextIcon,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '견적작성중',
|
||||
value: stats?.drafting ?? 0,
|
||||
icon: FilePenLine,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('drafting'),
|
||||
isActive: activeStatTab === 'drafting',
|
||||
},
|
||||
{
|
||||
label: '견적완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: FileCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedEstimates.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 견적자 필터 */}
|
||||
<Select value={estimatorFilter} onValueChange={setEstimatorFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="견적자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ESTIMATORS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTIMATE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTIMATE_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="견적관리"
|
||||
description="견적을 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="견적번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedEstimates}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedEstimates.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
288
src/components/business/juil/estimates/actions.ts
Normal file
288
src/components/business/juil/estimates/actions.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
'use server';
|
||||
|
||||
import type { Estimate, EstimateStats, EstimateFilter, EstimateListResponse } from './types';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 견적관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockEstimates: Estimate[] = [
|
||||
{
|
||||
id: '1',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '삼성 엘에이 사옥',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '마포 물류센터 증축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '여의 현장아파트',
|
||||
projectName: '여의도 상업시설 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 21,
|
||||
estimateAmount: 50000000,
|
||||
distributionDate: '2025-12-15',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '강남 오피스텔 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '서초 아파트 리모델링',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
projectName: '송파 주상복합 공사',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'awarded',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
estimateCode: '123125',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '판교 테크노밸리 빌딩',
|
||||
estimatorId: 'kim',
|
||||
estimatorName: '김철수',
|
||||
itemCount: 15,
|
||||
estimateAmount: 200000000,
|
||||
distributionDate: '2025-12-18',
|
||||
bidDate: '2025-12-20',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
];
|
||||
|
||||
// 견적 목록 조회
|
||||
export async function getEstimateList(
|
||||
filter?: EstimateFilter
|
||||
): Promise<{ success: boolean; data?: EstimateListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockEstimates];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.projectName.toLowerCase().includes(search) ||
|
||||
e.estimateCode.toLowerCase().includes(search) ||
|
||||
e.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((e) => e.status === filter.status);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((e) => e.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 견적자 필터
|
||||
if (filter?.estimatorId) {
|
||||
filtered = filtered.filter((e) => e.estimatorId === filter.estimatorId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt >= filter.startDate!);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt <= filter.endDate!);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filtered.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filtered.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
filtered.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateList error:', error);
|
||||
return { success: false, error: '견적 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 상세 조회
|
||||
export async function getEstimate(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Estimate; error?: string }> {
|
||||
try {
|
||||
const estimate = mockEstimates.find((e) => e.id === id);
|
||||
|
||||
if (!estimate) {
|
||||
return { success: false, error: '견적을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: estimate };
|
||||
} catch (error) {
|
||||
console.error('getEstimate error:', error);
|
||||
return { success: false, error: '견적 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 통계 조회
|
||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
||||
try {
|
||||
const total = mockEstimates.length;
|
||||
const drafting = mockEstimates.filter((e) => e.status === 'drafting').length;
|
||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
||||
const awarded = mockEstimates.filter((e) => e.awardStatus === 'awarded').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
drafting,
|
||||
completed,
|
||||
awarded,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 삭제
|
||||
export async function deleteEstimate(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Delete estimate:', id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimate error:', error);
|
||||
return { success: false, error: '견적 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 일괄 삭제
|
||||
export async function deleteEstimates(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
console.log('Delete estimates:', ids);
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimates error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
3
src/components/business/juil/estimates/index.ts
Normal file
3
src/components/business/juil/estimates/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as EstimateListClient } from './EstimateListClient';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
107
src/components/business/juil/estimates/types.ts
Normal file
107
src/components/business/juil/estimates/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 주일 기업 - 견적관리 타입 정의
|
||||
*/
|
||||
|
||||
// 견적 상태
|
||||
export type EstimateStatus = 'drafting' | 'completed';
|
||||
|
||||
// 낙찰 상태
|
||||
export type AwardStatus = 'pending' | 'awarded' | 'failed';
|
||||
|
||||
// 견적 타입
|
||||
export interface Estimate {
|
||||
id: string;
|
||||
estimateCode: string; // 견적번호
|
||||
|
||||
// 기본 정보
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
projectName: string; // 현장명
|
||||
estimatorId: string; // 견적자 ID
|
||||
estimatorName: string; // 견적자명
|
||||
|
||||
// 견적 정보
|
||||
itemCount: number; // 계 (품목 수)
|
||||
estimateAmount: number; // 견적금액
|
||||
distributionDate: string | null; // 견적배부일
|
||||
bidDate: string | null; // 입찰일
|
||||
|
||||
// 상태 정보
|
||||
status: EstimateStatus; // 견적 상태
|
||||
awardStatus: AwardStatus; // 낙찰 상태
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
// 견적 통계
|
||||
export interface EstimateStats {
|
||||
total: number; // 전체
|
||||
drafting: number; // 견적작성중
|
||||
completed: number; // 견적완료
|
||||
awarded: number; // 낙찰
|
||||
}
|
||||
|
||||
// 견적 필터
|
||||
export interface EstimateFilter {
|
||||
search?: string;
|
||||
status?: EstimateStatus | 'all';
|
||||
partnerId?: string;
|
||||
estimatorId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: 'latest' | 'oldest' | 'amountAsc' | 'amountDesc' | 'bidDateDesc';
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface EstimateListResponse {
|
||||
items: Estimate[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const ESTIMATE_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'drafting', label: '견적작성중' },
|
||||
{ value: 'completed', label: '견적완료' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const ESTIMATE_SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '견적금액 높은순' },
|
||||
{ value: 'amountAsc', label: '견적금액 낮은순' },
|
||||
{ value: 'bidDateDesc', label: '입찰일 최신순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
drafting: 'text-red-500 font-medium',
|
||||
completed: 'text-gray-600',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<EstimateStatus, string> = {
|
||||
drafting: '견적작성중',
|
||||
completed: '견적완료',
|
||||
};
|
||||
|
||||
// 낙찰 상태 라벨
|
||||
export const AWARD_STATUS_LABELS: Record<AwardStatus, string> = {
|
||||
pending: '-',
|
||||
awarded: '낙찰',
|
||||
failed: '유찰',
|
||||
};
|
||||
|
||||
export const AWARD_STATUS_STYLES: Record<AwardStatus, string> = {
|
||||
pending: 'text-gray-400',
|
||||
awarded: 'text-blue-600 font-medium',
|
||||
failed: 'text-red-500',
|
||||
};
|
||||
835
src/components/business/juil/partners/PartnerForm.tsx
Normal file
835
src/components/business/juil/partners/PartnerForm.tsx
Normal file
@@ -0,0 +1,835 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon } from 'lucide-react';
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { toast } from 'sonner';
|
||||
import type { Partner, PartnerFormData, PartnerMemo, PartnerDocument } from './types';
|
||||
import {
|
||||
PARTNER_TYPE_OPTIONS,
|
||||
CATEGORY_OPTIONS,
|
||||
CREDIT_RATING_OPTIONS,
|
||||
TRANSACTION_GRADE_OPTIONS,
|
||||
PAYMENT_DAY_OPTIONS,
|
||||
getEmptyPartnerFormData,
|
||||
partnerToFormData,
|
||||
} from './types';
|
||||
|
||||
interface PartnerFormProps {
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
partnerId?: string;
|
||||
initialData?: Partner;
|
||||
}
|
||||
|
||||
export default function PartnerForm({ mode, partnerId, initialData }: PartnerFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<PartnerFormData>(
|
||||
initialData ? partnerToFormData(initialData) : getEmptyPartnerFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 새 메모 입력
|
||||
const [newMemo, setNewMemo] = useState('');
|
||||
|
||||
// 파일 업로드 ref
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
zipCode: result.zonecode,
|
||||
address1: result.address,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: keyof PartnerFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/partners');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/partners/${partnerId}/edit`);
|
||||
}, [router, partnerId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isNewMode) {
|
||||
router.push('/ko/juil/project/bidding/partners');
|
||||
} else {
|
||||
router.push(`/ko/juil/project/bidding/partners/${partnerId}`);
|
||||
}
|
||||
}, [router, partnerId, isNewMode]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
if (!formData.partnerName.trim()) {
|
||||
toast.error('거래처명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setShowSaveDialog(true);
|
||||
}, [formData.partnerName]);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success(isNewMode ? '거래처가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/juil/project/bidding/partners');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/bidding/partners');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 메모 추가 핸들러
|
||||
const handleAddMemo = useCallback(() => {
|
||||
if (!newMemo.trim()) return;
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10);
|
||||
const timeStr = now.toTimeString().slice(0, 5);
|
||||
const memo: PartnerMemo = {
|
||||
id: String(Date.now()),
|
||||
content: `${dateStr} ${timeStr} [사용자] ${newMemo}`,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
memos: [...prev.memos, memo],
|
||||
}));
|
||||
setNewMemo('');
|
||||
}, [newMemo]);
|
||||
|
||||
// 메모 삭제 핸들러
|
||||
const handleDeleteMemo = useCallback((memoId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
memos: prev.memos.filter((m) => m.id !== memoId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 로고 업로드 핸들러
|
||||
const handleLogoUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 타입 검증
|
||||
if (!['image/png', 'image/jpeg', 'image/gif'].includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// BLOB으로 변환
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
logoBlob: reader.result as string,
|
||||
logoUrl: null,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
// 로고 삭제 핸들러
|
||||
const handleLogoRemove = useCallback(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
logoBlob: null,
|
||||
logoUrl: null,
|
||||
}));
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 업로드 핸들러
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: PartnerDocument = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
|
||||
if (documentInputRef.current) {
|
||||
documentInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 삭제 핸들러
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: prev.documents.filter((d) => d.id !== docId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 드래그앤드롭 핸들러
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isViewMode) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isViewMode]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isViewMode) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: PartnerDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
});
|
||||
}, [isViewMode]);
|
||||
|
||||
// 타이틀 및 설명
|
||||
const pageTitle = useMemo(() => {
|
||||
if (isNewMode) return '거래처 등록';
|
||||
if (isEditMode) return '거래처 수정';
|
||||
return '거래처 상세';
|
||||
}, [isNewMode, isEditMode]);
|
||||
|
||||
const pageDescription = useMemo(() => {
|
||||
if (isNewMode) return '새로운 거래처를 등록합니다';
|
||||
if (isEditMode) return '거래처 정보를 수정합니다';
|
||||
return '거래처 상세 정보 및 신용등급을 관리합니다';
|
||||
}, [isNewMode, isEditMode]);
|
||||
|
||||
// 헤더 버튼
|
||||
const headerActions = useMemo(() => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 등록 모드
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [isViewMode, isEditMode, isLoading, handleBack, handleEdit, handleDelete, handleCancel, handleSave]);
|
||||
|
||||
// 입력 필드 렌더링 헬퍼
|
||||
const renderField = (
|
||||
label: string,
|
||||
field: keyof PartnerFormData,
|
||||
value: string | number,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
type?: 'text' | 'tel' | 'email' | 'number';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { required, type = 'text', placeholder, disabled } = options || {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
disabled={isViewMode || disabled}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 셀렉트 필드 렌더링 헬퍼
|
||||
const renderSelectField = (
|
||||
label: string,
|
||||
field: keyof PartnerFormData,
|
||||
value: string,
|
||||
options: { value: string; label: string }[],
|
||||
required?: boolean
|
||||
) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Select value={value} onValueChange={(val) => handleChange(field, val)} disabled={isViewMode}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={Building2}
|
||||
actions={headerActions}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보 *</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('사업자등록번호', 'businessNumber', formData.businessNumber, {
|
||||
placeholder: '000-00-00000',
|
||||
})}
|
||||
{renderField('거래처 코드', 'partnerCode', formData.partnerCode || '', {
|
||||
placeholder: '자동생성',
|
||||
disabled: true,
|
||||
})}
|
||||
{renderField('거래처명', 'partnerName', formData.partnerName, { required: true })}
|
||||
{renderField('대표자명', 'representative', formData.representative)}
|
||||
{renderSelectField('거래처 유형', 'partnerType', formData.partnerType, PARTNER_TYPE_OPTIONS)}
|
||||
{renderField('업태', 'businessType', formData.businessType)}
|
||||
{renderSelectField('업종', 'category', formData.category, CATEGORY_OPTIONS)}
|
||||
{renderField('업종(직접입력)', 'businessCategory', formData.businessCategory)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 연락처 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">연락처 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isViewMode}
|
||||
className="shrink-0"
|
||||
onClick={() => openPostcode()}
|
||||
>
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
<Input
|
||||
value={formData.zipCode}
|
||||
onChange={(e) => handleChange('zipCode', e.target.value)}
|
||||
placeholder="우편번호"
|
||||
disabled
|
||||
className="w-[120px] bg-gray-50"
|
||||
/>
|
||||
<Input
|
||||
value={formData.address1}
|
||||
onChange={(e) => handleChange('address1', e.target.value)}
|
||||
placeholder="기본주소"
|
||||
disabled
|
||||
className="flex-1 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address2}
|
||||
onChange={(e) => handleChange('address2', e.target.value)}
|
||||
placeholder="상세주소"
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('전화번호', 'phone', formData.phone, {
|
||||
type: 'tel',
|
||||
placeholder: '02-0000-0000',
|
||||
})}
|
||||
{renderField('모바일', 'mobile', formData.mobile, {
|
||||
type: 'tel',
|
||||
placeholder: '010-0000-0000',
|
||||
})}
|
||||
{renderField('팩스', 'fax', formData.fax, {
|
||||
type: 'tel',
|
||||
placeholder: '02-0000-0000',
|
||||
})}
|
||||
{renderField('이메일', 'email', formData.email, { type: 'email' })}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 담당자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">담당자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('담당자명', 'manager', formData.manager)}
|
||||
{renderField('담당자 전화', 'managerPhone', formData.managerPhone, { type: 'tel' })}
|
||||
<div className="md:col-span-2">
|
||||
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 회사 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">회사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 회사 로고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">회사 로고</Label>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode ? 'bg-gray-50' : 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && logoInputRef.current?.click()}
|
||||
>
|
||||
{formData.logoBlob || formData.logoUrl ? (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<img
|
||||
src={formData.logoBlob || formData.logoUrl || ''}
|
||||
alt="회사 로고"
|
||||
className="max-h-[100px] max-w-[300px] object-contain"
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLogoRemove();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="w-12 h-12 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">750 X 250px, 10MB 이하의 PNG, JPEG, GIF</p>
|
||||
{!isViewMode && (
|
||||
<Button type="button" variant="outline" className="mt-2">
|
||||
이미지 업로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderSelectField(
|
||||
'매출 결제일',
|
||||
'salesPaymentDay',
|
||||
String(formData.salesPaymentDay || 15),
|
||||
PAYMENT_DAY_OPTIONS
|
||||
)}
|
||||
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_OPTIONS)}
|
||||
{renderSelectField(
|
||||
'거래등급',
|
||||
'transactionGrade',
|
||||
formData.transactionGrade,
|
||||
TRANSACTION_GRADE_OPTIONS
|
||||
)}
|
||||
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, {
|
||||
type: 'email',
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">추가 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 미수금 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">미수금</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.outstandingAmount?.toLocaleString() + '원'}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
{/* 연체 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">연체</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.overdueDays ? `${formData.overdueDays}일` : '-'}
|
||||
disabled
|
||||
className="bg-gray-50 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={formData.overdueToggle}
|
||||
onCheckedChange={(checked) => handleChange('overdueToggle', checked)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span className="text-sm">{formData.overdueToggle ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 악성채권 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">악성채권</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md text-sm flex-1 ${
|
||||
formData.badDebtToggle ? 'bg-red-100 text-red-800' : 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{formData.badDebtToggle ? '악성채권' : '-'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={formData.badDebtToggle}
|
||||
onCheckedChange={(checked) => handleChange('badDebtToggle', checked)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span className="text-sm">{formData.badDebtToggle ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<Label className="text-sm font-medium text-gray-700">메모</Label>
|
||||
{/* 메모 입력 */}
|
||||
{!isViewMode && (
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={newMemo}
|
||||
onChange={(e) => setNewMemo(e.target.value)}
|
||||
placeholder="메모를 입력하세요..."
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMemo}
|
||||
className="bg-orange-500 hover:bg-orange-600 self-end"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 메모 리스트 */}
|
||||
{formData.memos.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{formData.memos.map((memo) => (
|
||||
<div key={memo.id} className="flex items-start justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDeleteMemo(memo.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">등록된 메모가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 필요 서류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">필요 서류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
onChange={handleDocumentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && documentInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`w-12 h-12 mx-auto mb-2 ${isDragging ? 'text-primary' : 'text-gray-400'}`} />
|
||||
<p className="text-sm text-gray-600">
|
||||
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 첨부하거나, 마우스로 파일을 끌어오세요.'}
|
||||
</p>
|
||||
</div>
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{formData.documents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{formData.documents.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{doc.fileName}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(doc.fileSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDocumentRemove(doc.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{formData.partnerName}'을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
확인 클릭 시 거래처관리 목록으로 이동합니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{isNewMode ? '거래처 등록' : '수정 확인'}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isNewMode
|
||||
? '거래처를 등록하시겠습니까?'
|
||||
: '정말 수정하시겠습니까? 확인 클릭 시 "수정이 완료되었습니다." 알림이 표시됩니다.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -375,12 +375,27 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (필터 + 등록 버튼)
|
||||
// 헤더 액션 (등록 버튼만)
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedPartners.length}건
|
||||
</span>
|
||||
|
||||
{/* 악성채권 필터 */}
|
||||
<Select value={badDebtFilter} onValueChange={(v) => setBadDebtFilter(v as typeof badDebtFilter)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -392,22 +407,16 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">최신순</SelectItem>
|
||||
<SelectItem value="oldest">등록순</SelectItem>
|
||||
<SelectItem value="nameAsc">거래처명 오름차순</SelectItem>
|
||||
<SelectItem value="nameDesc">거래처명 내림차순</SelectItem>
|
||||
<SelectItem value="nameAsc">이름 오름차순</SelectItem>
|
||||
<SelectItem value="nameDesc">이름 내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 등록 버튼 */}
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -435,6 +444,7 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
tabs={tabOptions}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
'use server';
|
||||
|
||||
import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse } from './types';
|
||||
import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse, PartnerFormData } from './types';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 거래처 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
// 목업 데이터 (확장된 타입 적용)
|
||||
const mockPartners: Partner[] = [
|
||||
{
|
||||
id: '1',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '010-1234-1234',
|
||||
partnerCode: 'P-001',
|
||||
businessNumber: '123-12-12345',
|
||||
partnerName: '대한건설',
|
||||
representative: '홍길동',
|
||||
partnerType: 'sales',
|
||||
businessType: '건설업',
|
||||
businessCategory: '토목건축',
|
||||
zipCode: '06234',
|
||||
address1: '서울특별시 서초구 서초대로 123',
|
||||
address2: '대한건물 12층 1201호',
|
||||
phone: '02-1234-1234',
|
||||
mobile: '010-1234-1234',
|
||||
fax: '02-1234-1235',
|
||||
email: 'abc@email.com',
|
||||
manager: '담당자명',
|
||||
managerPhone: '010-1234-1234',
|
||||
systemManager: '관리자명',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 15,
|
||||
creditRating: 'AAA',
|
||||
transactionGrade: 'A',
|
||||
taxInvoiceEmail: 'abc@email.com',
|
||||
outstandingAmount: 11000000,
|
||||
overdueDays: 15,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: false,
|
||||
memos: [
|
||||
{
|
||||
id: '1',
|
||||
content: '2025-12-12 12:21 [홍길동] 메모 내용',
|
||||
createdAt: '2025-12-12T12:21:00Z',
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
@@ -25,13 +55,37 @@ const mockPartners: Partner[] = [
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '02-1234-1234',
|
||||
paymentDay: 15,
|
||||
partnerCode: 'P-002',
|
||||
businessNumber: '456-45-45678',
|
||||
partnerName: '삼성시공',
|
||||
representative: '김철수',
|
||||
partnerType: 'purchase',
|
||||
businessType: '시공업',
|
||||
businessCategory: '건축시공',
|
||||
zipCode: '06235',
|
||||
address1: '서울특별시 강남구 테헤란로 456',
|
||||
address2: '삼성빌딩 5층',
|
||||
phone: '02-5678-5678',
|
||||
mobile: '010-5678-5678',
|
||||
fax: '02-5678-5679',
|
||||
email: 'samsung@email.com',
|
||||
manager: '이영희',
|
||||
managerPhone: '010-5678-5678',
|
||||
systemManager: '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 10,
|
||||
creditRating: 'AA',
|
||||
transactionGrade: 'B',
|
||||
taxInvoiceEmail: 'tax@samsung.com',
|
||||
outstandingAmount: 5000000,
|
||||
overdueDays: 0,
|
||||
overdueToggle: false,
|
||||
badDebtToggle: false,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '시공사',
|
||||
paymentDay: 10,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-02',
|
||||
@@ -39,74 +93,42 @@ const mockPartners: Partner[] = [
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '010-1234-1234',
|
||||
paymentDay: 15,
|
||||
partnerCode: 'P-003',
|
||||
businessNumber: '789-78-78901',
|
||||
partnerName: 'LG건설',
|
||||
representative: '박영수',
|
||||
partnerType: 'both',
|
||||
businessType: '종합건설',
|
||||
businessCategory: '건설',
|
||||
zipCode: '06236',
|
||||
address1: '서울특별시 영등포구 여의대로 789',
|
||||
address2: 'LG타워 20층',
|
||||
phone: '02-7890-7890',
|
||||
mobile: '010-7890-7890',
|
||||
fax: '02-7890-7891',
|
||||
email: 'lg@email.com',
|
||||
manager: '최민수',
|
||||
managerPhone: '010-7890-7890',
|
||||
systemManager: '시스템관리자',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 20,
|
||||
creditRating: 'BBB',
|
||||
transactionGrade: 'C',
|
||||
taxInvoiceEmail: 'tax@lg.com',
|
||||
outstandingAmount: 20000000,
|
||||
overdueDays: 30,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: true,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 20,
|
||||
isBadDebt: true,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '02-1234-1234',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '010-1234-1234',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '010-1234-1234',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
partnerCode: '123123',
|
||||
category: '매출',
|
||||
partnerName: '회사명',
|
||||
representative: '이름',
|
||||
manager: '이름',
|
||||
phone: '010-1234-1234',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
},
|
||||
];
|
||||
|
||||
// 거래처 목록 조회
|
||||
@@ -115,8 +137,6 @@ export async function getPartnerList(
|
||||
): Promise<{ success: boolean; data?: PartnerListResponse; error?: string }> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
// const response = await fetch(`${API_URL}/partners`, { ... });
|
||||
|
||||
let filtered = [...mockPartners];
|
||||
|
||||
// 검색 필터
|
||||
@@ -176,6 +196,136 @@ export async function getPartnerList(
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 상세 조회
|
||||
export async function getPartner(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
const partner = mockPartners.find((p) => p.id === id);
|
||||
|
||||
if (!partner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: partner };
|
||||
} catch (error) {
|
||||
console.error('getPartner error:', error);
|
||||
return { success: false, error: '거래처 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 등록
|
||||
export async function createPartner(
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Create partner:', data);
|
||||
|
||||
const newPartner: Partner = {
|
||||
id: String(Date.now()),
|
||||
partnerCode: `P-${String(mockPartners.length + 1).padStart(3, '0')}`,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: newPartner };
|
||||
} catch (error) {
|
||||
console.error('createPartner error:', error);
|
||||
return { success: false, error: '거래처 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 수정
|
||||
export async function updatePartner(
|
||||
id: string,
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Update partner:', id, data);
|
||||
|
||||
const existingPartner = mockPartners.find((p) => p.id === id);
|
||||
if (!existingPartner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedPartner: Partner = {
|
||||
...existingPartner,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: updatedPartner };
|
||||
} catch (error) {
|
||||
console.error('updatePartner error:', error);
|
||||
return { success: false, error: '거래처 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 통계 조회
|
||||
export async function getPartnerStats(): Promise<{ success: boolean; data?: PartnerStats; error?: string }> {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as PartnerListClient } from './PartnerListClient';
|
||||
export { default as PartnerForm } from './PartnerForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
@@ -2,18 +2,75 @@
|
||||
* 주일 기업 - 거래처 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 메모 타입
|
||||
export interface PartnerMemo {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 필요 서류 타입
|
||||
export interface PartnerDocument {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 거래처 타입
|
||||
export interface Partner {
|
||||
id: string;
|
||||
partnerCode: string; // 거래처번호
|
||||
category: string; // 구분 (건설사, 시공사 등)
|
||||
partnerCode: string; // 거래처번호 (자동생성)
|
||||
|
||||
// 기본 정보
|
||||
businessNumber: string; // 사업자등록번호
|
||||
partnerName: string; // 거래처명
|
||||
representative: string; // 대표자
|
||||
manager: string; // 담당자
|
||||
representative: string; // 대표자명
|
||||
partnerType: 'sales' | 'purchase' | 'both'; // 거래처 유형 (매출/매입/복합)
|
||||
businessType: string; // 업태
|
||||
businessCategory: string; // 업종
|
||||
|
||||
// 연락처 정보
|
||||
zipCode: string; // 우편번호
|
||||
address1: string; // 기본주소
|
||||
address2: string; // 상세주소
|
||||
phone: string; // 전화번호
|
||||
mobile: string; // 모바일
|
||||
fax: string; // 팩스
|
||||
email: string; // 이메일
|
||||
|
||||
// 담당자 정보
|
||||
manager: string; // 담당자명
|
||||
managerPhone: string; // 담당자 전화
|
||||
systemManager: string; // 시스템 관리자
|
||||
|
||||
// 회사 정보
|
||||
logoUrl: string | null; // 회사 로고 URL
|
||||
logoBlob: string | null; // 회사 로고 BLOB (Base64)
|
||||
salesPaymentDay: number | null; // 매출 결제일 (1~31)
|
||||
|
||||
// 신용/거래 정보
|
||||
creditRating: string; // 신용등급 (AAA, AA, A, BBB, BB, B, CCC, CC, C, D)
|
||||
transactionGrade: string; // 거래등급 (A, B, C, D, E)
|
||||
taxInvoiceEmail: string; // 세금계산서 이메일
|
||||
|
||||
// 추가 정보
|
||||
outstandingAmount: number; // 미수금
|
||||
overdueDays: number; // 연체일수
|
||||
overdueToggle: boolean; // 연체 ON/OFF
|
||||
badDebtToggle: boolean; // 악성채권 ON/OFF
|
||||
memos: PartnerMemo[]; // 메모 리스트
|
||||
|
||||
// 필요 서류
|
||||
documents: PartnerDocument[];
|
||||
|
||||
// 리스트용 필드 (하위 호환)
|
||||
category: string; // 구분 (건설사, 시공사 등)
|
||||
paymentDay: number | null; // 매출 결제일
|
||||
isBadDebt: boolean; // 악성채권 여부
|
||||
isActive: boolean; // 활성 상태
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -37,14 +94,51 @@ export interface PartnerFilter {
|
||||
|
||||
// 거래처 폼 데이터
|
||||
export interface PartnerFormData {
|
||||
// 기본 정보
|
||||
businessNumber: string;
|
||||
partnerCode?: string;
|
||||
category: string;
|
||||
partnerName: string;
|
||||
representative: string;
|
||||
manager: string;
|
||||
partnerType: 'sales' | 'purchase' | 'both';
|
||||
businessType: string;
|
||||
businessCategory: string;
|
||||
|
||||
// 연락처 정보
|
||||
zipCode: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
phone: string;
|
||||
paymentDay: number | null;
|
||||
isBadDebt: boolean;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
|
||||
// 담당자 정보
|
||||
manager: string;
|
||||
managerPhone: string;
|
||||
systemManager: string;
|
||||
|
||||
// 회사 정보
|
||||
logoUrl: string | null;
|
||||
logoBlob: string | null;
|
||||
salesPaymentDay: number | null;
|
||||
|
||||
// 신용/거래 정보
|
||||
creditRating: string;
|
||||
transactionGrade: string;
|
||||
taxInvoiceEmail: string;
|
||||
|
||||
// 추가 정보
|
||||
outstandingAmount: number;
|
||||
overdueDays: number;
|
||||
overdueToggle: boolean;
|
||||
badDebtToggle: boolean;
|
||||
memos: PartnerMemo[];
|
||||
|
||||
// 필요 서류
|
||||
documents: PartnerDocument[];
|
||||
|
||||
// 리스트용 필드
|
||||
category: string;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
@@ -54,4 +148,111 @@ export interface PartnerListResponse {
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
}
|
||||
|
||||
// 셀렉트 옵션들
|
||||
export const PARTNER_TYPE_OPTIONS = [
|
||||
{ value: 'sales', label: '매출' },
|
||||
{ value: 'purchase', label: '매입' },
|
||||
{ value: 'both', label: '복합' },
|
||||
];
|
||||
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: '건설사', label: '건설사' },
|
||||
{ value: '시공사', label: '시공사' },
|
||||
{ value: '기타', label: '기타' },
|
||||
];
|
||||
|
||||
export const CREDIT_RATING_OPTIONS = [
|
||||
{ value: 'AAA', label: 'AAA' },
|
||||
{ value: 'AA', label: 'AA' },
|
||||
{ value: 'A', label: 'A' },
|
||||
{ value: 'BBB', label: 'BBB' },
|
||||
{ value: 'BB', label: 'BB' },
|
||||
{ value: 'B', label: 'B' },
|
||||
{ value: 'CCC', label: 'CCC' },
|
||||
{ value: 'CC', label: 'CC' },
|
||||
{ value: 'C', label: 'C' },
|
||||
{ value: 'D', label: 'D' },
|
||||
];
|
||||
|
||||
export const TRANSACTION_GRADE_OPTIONS = [
|
||||
{ value: 'A', label: 'A(우수)' },
|
||||
{ value: 'B', label: 'B(양호)' },
|
||||
{ value: 'C', label: 'C(보통)' },
|
||||
{ value: 'D', label: 'D(주의)' },
|
||||
{ value: 'E', label: 'E(위험)' },
|
||||
];
|
||||
|
||||
export const PAYMENT_DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: `${i + 1}일`,
|
||||
}));
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export const getEmptyPartnerFormData = (): PartnerFormData => ({
|
||||
businessNumber: '',
|
||||
partnerCode: '',
|
||||
partnerName: '',
|
||||
representative: '',
|
||||
partnerType: 'sales',
|
||||
businessType: '',
|
||||
businessCategory: '',
|
||||
zipCode: '',
|
||||
address1: '',
|
||||
address2: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
fax: '',
|
||||
email: '',
|
||||
manager: '',
|
||||
managerPhone: '',
|
||||
systemManager: '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 15,
|
||||
creditRating: 'A',
|
||||
transactionGrade: 'C',
|
||||
taxInvoiceEmail: '',
|
||||
outstandingAmount: 0,
|
||||
overdueDays: 0,
|
||||
overdueToggle: false,
|
||||
badDebtToggle: false,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
});
|
||||
|
||||
// Partner → PartnerFormData 변환
|
||||
export const partnerToFormData = (partner: Partner): PartnerFormData => ({
|
||||
businessNumber: partner.businessNumber || '',
|
||||
partnerCode: partner.partnerCode || '',
|
||||
partnerName: partner.partnerName || '',
|
||||
representative: partner.representative || '',
|
||||
partnerType: partner.partnerType || 'sales',
|
||||
businessType: partner.businessType || '',
|
||||
businessCategory: partner.businessCategory || '',
|
||||
zipCode: partner.zipCode || '',
|
||||
address1: partner.address1 || '',
|
||||
address2: partner.address2 || '',
|
||||
phone: partner.phone || '',
|
||||
mobile: partner.mobile || '',
|
||||
fax: partner.fax || '',
|
||||
email: partner.email || '',
|
||||
manager: partner.manager || '',
|
||||
managerPhone: partner.managerPhone || '',
|
||||
systemManager: partner.systemManager || '',
|
||||
logoUrl: partner.logoUrl || null,
|
||||
logoBlob: partner.logoBlob || null,
|
||||
salesPaymentDay: partner.salesPaymentDay ?? partner.paymentDay ?? 15,
|
||||
creditRating: partner.creditRating || 'A',
|
||||
transactionGrade: partner.transactionGrade || 'C',
|
||||
taxInvoiceEmail: partner.taxInvoiceEmail || '',
|
||||
outstandingAmount: partner.outstandingAmount || 0,
|
||||
overdueDays: partner.overdueDays || 0,
|
||||
overdueToggle: partner.overdueToggle || false,
|
||||
badDebtToggle: partner.badDebtToggle || partner.isBadDebt || false,
|
||||
memos: partner.memos || [],
|
||||
documents: partner.documents || [],
|
||||
category: partner.category || '건설사',
|
||||
});
|
||||
687
src/components/business/juil/site-briefings/SiteBriefingForm.tsx
Normal file
687
src/components/business/juil/site-briefings/SiteBriefingForm.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic } from 'lucide-react';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { toast } from 'sonner';
|
||||
import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument } from './types';
|
||||
import {
|
||||
BRIEFING_TYPE_OPTIONS,
|
||||
ATTENDANCE_STATUS_OPTIONS,
|
||||
VAT_TYPE_OPTIONS,
|
||||
getEmptySiteBriefingFormData,
|
||||
siteBriefingToFormData,
|
||||
} from './types';
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '대한건설' },
|
||||
{ value: '3', label: '삼성시공' },
|
||||
];
|
||||
|
||||
// 목업 참석자 목록
|
||||
const MOCK_ATTENDEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
interface SiteBriefingFormProps {
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
briefingId?: string;
|
||||
initialData?: SiteBriefing;
|
||||
}
|
||||
|
||||
export default function SiteBriefingForm({ mode, briefingId, initialData }: SiteBriefingFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<SiteBriefingFormData>(
|
||||
initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/site-briefings');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/site-briefings/${briefingId}/edit`);
|
||||
}, [router, briefingId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isNewMode) {
|
||||
router.push('/ko/juil/project/bidding/site-briefings');
|
||||
} else {
|
||||
router.push(`/ko/juil/project/bidding/site-briefings/${briefingId}`);
|
||||
}
|
||||
}, [router, briefingId, isNewMode]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
if (!formData.projectName.trim()) {
|
||||
toast.error('현장명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setShowSaveDialog(true);
|
||||
}, [formData.projectName]);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success(isNewMode ? '현장설명회가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/juil/project/bidding/site-briefings');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('현장설명회가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/bidding/site-briefings');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 참여업체 추가 핸들러
|
||||
const handleAddCompany = useCallback(() => {
|
||||
const newCompany: ParticipatingCompany = {
|
||||
id: String(Date.now()),
|
||||
companyName: '',
|
||||
memo: '',
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participatingCompanies: [...prev.participatingCompanies, newCompany],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참여업체 삭제 핸들러
|
||||
const handleRemoveCompany = useCallback((companyId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participatingCompanies: prev.participatingCompanies.filter((c) => c.id !== companyId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참여업체 변경 핸들러
|
||||
const handleCompanyChange = useCallback(
|
||||
(companyId: string, field: keyof ParticipatingCompany, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participatingCompanies: prev.participatingCompanies.map((c) =>
|
||||
c.id === companyId ? { ...c, [field]: value } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 문서 업로드 핸들러
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BriefingDocument = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
|
||||
if (documentInputRef.current) {
|
||||
documentInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 삭제 핸들러
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: prev.documents.filter((d) => d.id !== docId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 드래그앤드롭 핸들러
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isViewMode) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isViewMode) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BriefingDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
});
|
||||
},
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
// 타이틀 및 설명
|
||||
const pageTitle = useMemo(() => {
|
||||
if (isNewMode) return '현장설명회 등록';
|
||||
if (isEditMode) return '현장설명회 수정';
|
||||
return '현장설명회 상세';
|
||||
}, [isNewMode, isEditMode]);
|
||||
|
||||
const pageDescription = useMemo(() => {
|
||||
if (isNewMode) return '새로운 현장설명회를 등록합니다';
|
||||
if (isEditMode) return '현장설명회 정보를 수정합니다';
|
||||
return '현장설명회 상세 정보를 확인합니다';
|
||||
}, [isNewMode, isEditMode]);
|
||||
|
||||
// 헤더 버튼 (PartnerForm과 동일한 구조)
|
||||
const headerActions = useMemo(() => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 등록 모드
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [isViewMode, isEditMode, isLoading, handleBack, handleEdit, handleDelete, handleCancel, handleSave]);
|
||||
|
||||
// 입력 필드 렌더링 헬퍼
|
||||
const renderField = (
|
||||
label: string,
|
||||
field: keyof SiteBriefingFormData,
|
||||
value: string | number,
|
||||
options?: {
|
||||
required?: boolean;
|
||||
type?: 'text' | 'tel' | 'email' | 'number' | 'date' | 'time';
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { required, type = 'text', placeholder, disabled } = options || {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
disabled={isViewMode || disabled}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 셀렉트 필드 렌더링 헬퍼
|
||||
const renderSelectField = (
|
||||
label: string,
|
||||
field: keyof SiteBriefingFormData,
|
||||
value: string,
|
||||
options: { value: string; label: string }[],
|
||||
required?: boolean
|
||||
) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Select value={value} onValueChange={(val) => handleChange(field, val)} disabled={isViewMode}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={Calendar}
|
||||
actions={headerActions}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보 *</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('현설번호', 'briefingCode', formData.briefingCode, {
|
||||
placeholder: '123123',
|
||||
})}
|
||||
{renderSelectField('거래처명', 'partnerId', formData.partnerId, MOCK_PARTNERS, true)}
|
||||
{renderField('현장설명회 일자', 'briefingDate', formData.briefingDate, {
|
||||
type: 'date',
|
||||
required: true,
|
||||
})}
|
||||
{/* 현장설명회 시간 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">현장설명회 시간</Label>
|
||||
<TimePicker
|
||||
value={formData.briefingTime}
|
||||
onChange={(value) => handleChange('briefingTime', value)}
|
||||
placeholder="시간 선택"
|
||||
disabled={isViewMode}
|
||||
minuteStep={1}
|
||||
/>
|
||||
</div>
|
||||
{renderSelectField('구분', 'briefingType', formData.briefingType, BRIEFING_TYPE_OPTIONS)}
|
||||
{renderField('현장설명회 장소', 'location', formData.location, {
|
||||
placeholder: '장소명',
|
||||
})}
|
||||
{renderSelectField('참석자', 'attendee', formData.attendee, MOCK_ATTENDEES)}
|
||||
{renderSelectField('상태', 'attendanceStatus', formData.attendanceStatus, ATTENDANCE_STATUS_OPTIONS)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 입찰 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">입찰 정보 *</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('현장명', 'projectName', formData.projectName, {
|
||||
required: true,
|
||||
placeholder: '현장명',
|
||||
})}
|
||||
{renderField('입찰일자', 'bidDate', formData.bidDate, {
|
||||
type: 'date',
|
||||
})}
|
||||
{renderField('개소', 'siteCount', formData.siteCount, {
|
||||
type: 'number',
|
||||
})}
|
||||
{/* 공사기간 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) => handleChange('constructionStartDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) => handleChange('constructionEndDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부가세 */}
|
||||
{renderSelectField('부가세', 'vatType', formData.vatType, VAT_TYPE_OPTIONS)}
|
||||
|
||||
{/* 업무 보고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">업무 보고</Label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={formData.workReport}
|
||||
onChange={(e) => handleChange('workReport', e.target.value)}
|
||||
placeholder="업무 보고 내용을 입력해주세요."
|
||||
disabled={isViewMode}
|
||||
className="flex-1 bg-white"
|
||||
rows={3}
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="self-end"
|
||||
onClick={() => toast.info('녹음 기능은 추후 구현 예정입니다.')}
|
||||
>
|
||||
<Mic className="h-4 w-4 mr-1" />
|
||||
녹음
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현장설명회 자료 */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium text-gray-700">현장설명회 자료</Label>
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
onChange={handleDocumentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && documentInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`w-12 h-12 mx-auto mb-2 ${isDragging ? 'text-primary' : 'text-gray-400'}`} />
|
||||
<p className="text-sm text-gray-600">
|
||||
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
|
||||
</p>
|
||||
</div>
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{formData.documents.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm">{doc.fileName}</span>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleDocumentRemove(doc.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 현장설명회 참여업체 및 메모 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">현장설명회 참여업체 및 메모</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCompany}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formData.participatingCompanies.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
등록된 참여업체가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{formData.participatingCompanies.map((company, index) => (
|
||||
<div key={company.id} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-gray-100 rounded text-sm font-medium text-gray-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">참여업체</Label>
|
||||
<Input
|
||||
value={company.companyName}
|
||||
onChange={(e) => handleCompanyChange(company.id, 'companyName', e.target.value)}
|
||||
placeholder="업체명"
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">메모</Label>
|
||||
<Input
|
||||
value={company.memo}
|
||||
onChange={(e) => handleCompanyChange(company.id, 'memo', e.target.value)}
|
||||
placeholder="메모"
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleRemoveCompany(company.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장설명회 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{formData.projectName}'을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
확인 클릭 시 현장설명회 목록으로 이동합니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{isNewMode ? '현장설명회 등록' : '수정 확인'}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isNewMode
|
||||
? '현장설명회를 등록하시겠습니까?'
|
||||
: '정말 수정하시겠습니까? 확인 클릭 시 "수정이 완료되었습니다." 알림이 표시됩니다.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { SiteBriefing } from './types';
|
||||
import { getSiteBriefingList, deleteSiteBriefing, deleteSiteBriefings } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의 (스크린샷 기준)
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'briefingCode', label: '현설번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'briefingDate', label: '현장설명회일', className: 'w-[120px] text-center' },
|
||||
{ key: 'briefingType', label: '구분', className: 'w-[80px] text-center' },
|
||||
{ key: 'attendee', label: '참석자', className: 'w-[100px] text-center' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 상태 옵션
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'scheduled', label: '참석예정' },
|
||||
{ value: 'attended', label: '참석완료' },
|
||||
{ value: 'absent', label: '불참' },
|
||||
];
|
||||
|
||||
// 구분 옵션
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'online', label: '온라인' },
|
||||
{ value: 'offline', label: '오프라인' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'bidDateDesc', label: '입찰일 최신순' },
|
||||
{ value: 'partnerAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
scheduled: 'text-red-500 font-medium',
|
||||
attended: 'text-gray-900',
|
||||
absent: 'text-gray-500',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
scheduled: '참석예정',
|
||||
attended: '참석완료',
|
||||
absent: '불참',
|
||||
};
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '1', label: '대한건설' },
|
||||
{ value: '2', label: '삼성시공' },
|
||||
{ value: '3', label: 'LG건설' },
|
||||
];
|
||||
|
||||
// 목업 참석자 목록
|
||||
const MOCK_ATTENDEES = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
interface SiteBriefingListClientProps {
|
||||
initialData?: SiteBriefing[];
|
||||
}
|
||||
|
||||
export default function SiteBriefingListClient({ initialData = [] }: SiteBriefingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [briefings, setBriefings] = useState<SiteBriefing[]>(initialData);
|
||||
const [statsData, setStatsData] = useState({ total: 0, scheduled: 0, attended: 0 });
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'scheduled' | 'attended'>('all');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilter, setPartnerFilter] = useState<string>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [attendeeFilter, setAttendeeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const listResult = await getSiteBriefingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBriefings(listResult.data.items);
|
||||
// 목업 통계 계산 (참석 상태 기준)
|
||||
const items = listResult.data.items;
|
||||
const total = items.length;
|
||||
// 목업: scheduled 상태는 참석예정, 나머지는 참석완료로 처리
|
||||
const scheduled = items.filter((b) => b.status === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.status !== 'scheduled').length;
|
||||
setStatsData({ total, scheduled, attended });
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBriefings = useMemo(() => {
|
||||
return briefings.filter((briefing) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'scheduled' && briefing.status !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.status === 'scheduled') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilter !== 'all' && briefing.partnerId !== partnerFilter) return false;
|
||||
|
||||
// 구분 필터 (목업에서는 모두 통과)
|
||||
// if (typeFilter !== 'all') return false;
|
||||
|
||||
// 참석자 필터 (목업에서는 모두 통과)
|
||||
// if (attendeeFilter !== 'all') return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && briefing.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
briefing.title.toLowerCase().includes(search) ||
|
||||
briefing.briefingCode.toLowerCase().includes(search) ||
|
||||
briefing.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [briefings, activeStatTab, partnerFilter, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBriefings = useMemo(() => {
|
||||
const sorted = [...filteredBriefings];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'partnerDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
case 'projectAsc':
|
||||
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case 'projectDesc':
|
||||
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBriefings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBriefings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBriefings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBriefings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(briefing: SiteBriefing) => {
|
||||
router.push(`/ko/juil/project/bidding/site-briefings/${briefing.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/site-briefings/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, briefingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/bidding/site-briefings/${briefingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, briefingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(briefingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteSiteBriefing(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('현장설명회가 삭제되었습니다.');
|
||||
setBriefings((prev) => prev.filter((b) => b.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteSiteBriefings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(briefing.id);
|
||||
// 목업 데이터에서 상태 매핑
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={briefing.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(briefing)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(briefing.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{briefing.briefingCode}</TableCell>
|
||||
<TableCell>{briefing.partnerName}</TableCell>
|
||||
<TableCell>{briefing.title}</TableCell>
|
||||
<TableCell className="text-center">{briefing.briefingDate}</TableCell>
|
||||
<TableCell className="text-center">온라인</TableCell>
|
||||
<TableCell className="text-center">홍길동</TableCell>
|
||||
<TableCell className="text-center">{briefing.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[displayStatus]}>
|
||||
{STATUS_LABELS[displayStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, briefing.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, briefing.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
title={briefing.title}
|
||||
subtitle={briefing.briefingCode}
|
||||
badge={STATUS_LABELS[displayStatus]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(briefing)}
|
||||
details={[
|
||||
{ label: '거래처', value: briefing.partnerName },
|
||||
{ label: '현장설명회일', value: briefing.briefingDate },
|
||||
{ label: '구분', value: '온라인' },
|
||||
{ label: '참석자', value: '홍길동' },
|
||||
{ label: '입찰일', value: briefing.bidDate || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (등록 버튼 + 날짜 필터)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
현장설명회 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 현장설명회',
|
||||
value: statsData.total,
|
||||
icon: CalendarDays,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '참석예정 현장설명회',
|
||||
value: statsData.scheduled,
|
||||
icon: CalendarClock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('scheduled'),
|
||||
isActive: activeStatTab === 'scheduled',
|
||||
},
|
||||
{
|
||||
label: '참석완료 현장설명회',
|
||||
value: statsData.attended,
|
||||
icon: CalendarCheck,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('attended'),
|
||||
isActive: activeStatTab === 'attended',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBriefings.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 참석자 필터 */}
|
||||
<Select value={attendeeFilter} onValueChange={setAttendeeFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="참석자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ATTENDEES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="현장설명회 관리"
|
||||
description="현장설명회를 등록하고 관리합니다"
|
||||
icon={Calendar}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="현장번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBriefings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBriefings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장설명회 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 현장설명회를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장설명회 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 현장설명회를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/business/juil/site-briefings/actions.ts
Normal file
257
src/components/business/juil/site-briefings/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
'use server';
|
||||
|
||||
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse } from './types';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 현장설명회 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockSiteBriefings: SiteBriefing[] = [
|
||||
{
|
||||
id: '1',
|
||||
briefingCode: 'SB-001',
|
||||
title: '강남 오피스텔 신축공사',
|
||||
description: '강남구 삼성동 오피스텔 신축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '14:00',
|
||||
location: '강남구청 대회의실',
|
||||
address: '서울특별시 강남구 학동로 426',
|
||||
status: 'scheduled',
|
||||
bidStatus: 'pending',
|
||||
bidDate: '2025-05-15',
|
||||
attendees: [],
|
||||
attendeeCount: 5,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
briefingCode: 'SB-002',
|
||||
title: '서초 아파트 리모델링',
|
||||
description: '서초구 반포동 아파트 리모델링 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '10:00',
|
||||
location: '서초구청 소회의실',
|
||||
address: '서울특별시 서초구 남부순환로 2584',
|
||||
status: 'ongoing',
|
||||
bidStatus: 'bidding',
|
||||
bidDate: '2025-05-18',
|
||||
attendees: [],
|
||||
attendeeCount: 8,
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
briefingCode: 'SB-003',
|
||||
title: '여의도 상업시설 신축',
|
||||
description: '영등포구 여의도동 상업시설 신축 현장설명회',
|
||||
partnerId: '3',
|
||||
partnerName: 'LG건설',
|
||||
briefingDate: '2025-05-13',
|
||||
briefingTime: '15:00',
|
||||
location: 'LG트윈타워 회의실',
|
||||
address: '서울특별시 영등포구 여의대로 128',
|
||||
status: 'completed',
|
||||
bidStatus: 'awarded',
|
||||
bidDate: '2025-05-20',
|
||||
attendees: [],
|
||||
attendeeCount: 12,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '박영수',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
briefingCode: 'SB-004',
|
||||
title: '송파 주상복합 공사',
|
||||
description: '송파구 잠실동 주상복합 건축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-14',
|
||||
briefingTime: '11:00',
|
||||
location: '롯데월드타워 회의실',
|
||||
address: '서울특별시 송파구 올림픽로 300',
|
||||
status: 'cancelled',
|
||||
bidStatus: 'failed',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '최민수',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
briefingCode: 'SB-005',
|
||||
title: '마포 물류센터 증축',
|
||||
description: '마포구 상암동 물류센터 증축 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-15',
|
||||
briefingTime: '09:00',
|
||||
location: '상암 DMC 회의실',
|
||||
address: '서울특별시 마포구 상암산로 76',
|
||||
status: 'postponed',
|
||||
bidStatus: 'pending',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 3,
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '이영희',
|
||||
},
|
||||
];
|
||||
|
||||
// 현장설명회 목록 조회
|
||||
export async function getSiteBriefingList(
|
||||
filter?: SiteBriefingFilter
|
||||
): Promise<{ success: boolean; data?: SiteBriefingListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockSiteBriefings];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
b.briefingCode.toLowerCase().includes(search) ||
|
||||
b.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((b) => b.status === filter.status);
|
||||
}
|
||||
|
||||
// 입찰 상태 필터
|
||||
if (filter?.bidStatus && filter.bidStatus !== 'all') {
|
||||
filtered = filtered.filter((b) => b.bidStatus === filter.bidStatus);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((b) => b.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate >= filter.startDate!);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate <= filter.endDate!);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'dateAsc':
|
||||
filtered.sort((a, b) => new Date(a.briefingDate).getTime() - new Date(b.briefingDate).getTime());
|
||||
break;
|
||||
case 'dateDesc':
|
||||
filtered.sort((a, b) => new Date(b.briefingDate).getTime() - new Date(a.briefingDate).getTime());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefingList error:', error);
|
||||
return { success: false, error: '현장설명회 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 상세 조회
|
||||
export async function getSiteBriefing(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: SiteBriefing; error?: string }> {
|
||||
try {
|
||||
const briefing = mockSiteBriefings.find((b) => b.id === id);
|
||||
|
||||
if (!briefing) {
|
||||
return { success: false, error: '현장설명회를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: briefing };
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefing error:', error);
|
||||
return { success: false, error: '현장설명회 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 통계 조회
|
||||
export async function getSiteBriefingStats(): Promise<{ success: boolean; data?: SiteBriefingStats; error?: string }> {
|
||||
try {
|
||||
const total = mockSiteBriefings.length;
|
||||
const scheduled = mockSiteBriefings.filter((b) => b.status === 'scheduled').length;
|
||||
const ongoing = mockSiteBriefings.filter((b) => b.status === 'ongoing').length;
|
||||
const completed = mockSiteBriefings.filter((b) => b.status === 'completed').length;
|
||||
const cancelled = mockSiteBriefings.filter((b) => b.status === 'cancelled' || b.status === 'postponed').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
scheduled,
|
||||
ongoing,
|
||||
completed,
|
||||
cancelled,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefingStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 삭제
|
||||
export async function deleteSiteBriefing(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefing:', id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefing error:', error);
|
||||
return { success: false, error: '현장설명회 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 일괄 삭제
|
||||
export async function deleteSiteBriefings(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefings:', ids);
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefings error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
4
src/components/business/juil/site-briefings/index.ts
Normal file
4
src/components/business/juil/site-briefings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as SiteBriefingListClient } from './SiteBriefingListClient';
|
||||
export { default as SiteBriefingForm } from './SiteBriefingForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
258
src/components/business/juil/site-briefings/types.ts
Normal file
258
src/components/business/juil/site-briefings/types.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 주일 기업 - 현장설명회 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 현장설명회 상태
|
||||
export type SiteBriefingStatus = 'scheduled' | 'ongoing' | 'completed' | 'cancelled' | 'postponed';
|
||||
|
||||
// 입찰 상태
|
||||
export type BidStatus = 'pending' | 'bidding' | 'closed' | 'failed' | 'awarded';
|
||||
|
||||
// 참석자 타입
|
||||
export interface Attendee {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
isAttended: boolean;
|
||||
}
|
||||
|
||||
// 현장설명회 타입
|
||||
export interface SiteBriefing {
|
||||
id: string;
|
||||
briefingCode: string; // 현장설명회 코드
|
||||
|
||||
// 기본 정보
|
||||
title: string; // 현장설명회명
|
||||
description: string; // 설명
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
|
||||
// 일정 정보
|
||||
briefingDate: string; // 현장설명회 날짜
|
||||
briefingTime: string; // 현장설명회 시간
|
||||
location: string; // 장소
|
||||
address: string; // 주소
|
||||
|
||||
// 상태 정보
|
||||
status: SiteBriefingStatus; // 현장설명회 상태
|
||||
bidStatus: BidStatus; // 입찰 상태
|
||||
bidDate: string | null; // 입찰 날짜
|
||||
|
||||
// 참석자 정보
|
||||
attendees: Attendee[];
|
||||
attendeeCount: number; // 참석자 수
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
// 현장설명회 통계
|
||||
export interface SiteBriefingStats {
|
||||
total: number; // 전체
|
||||
scheduled: number; // 예정
|
||||
ongoing: number; // 진행중
|
||||
completed: number; // 완료
|
||||
cancelled: number; // 취소
|
||||
}
|
||||
|
||||
// 현장설명회 필터
|
||||
export interface SiteBriefingFilter {
|
||||
search?: string;
|
||||
status?: SiteBriefingStatus | 'all';
|
||||
bidStatus?: BidStatus | 'all';
|
||||
partnerId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: 'latest' | 'oldest' | 'dateAsc' | 'dateDesc';
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface SiteBriefingListResponse {
|
||||
items: SiteBriefing[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const SITE_BRIEFING_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'scheduled', label: '입찰예정' },
|
||||
{ value: 'ongoing', label: '입찰' },
|
||||
{ value: 'completed', label: '마감' },
|
||||
{ value: 'cancelled', label: '유찰' },
|
||||
{ value: 'postponed', label: '현장설명회 유찰' },
|
||||
];
|
||||
|
||||
export const BID_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'pending', label: '대기' },
|
||||
{ value: 'bidding', label: '입찰중' },
|
||||
{ value: 'closed', label: '마감' },
|
||||
{ value: 'failed', label: '유찰' },
|
||||
{ value: 'awarded', label: '낙찰' },
|
||||
];
|
||||
|
||||
// 상태별 배지 색상
|
||||
export const STATUS_BADGE_COLORS: Record<SiteBriefingStatus, string> = {
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
ongoing: 'bg-orange-100 text-orange-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
postponed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export const BID_STATUS_BADGE_COLORS: Record<BidStatus, string> = {
|
||||
pending: 'bg-gray-100 text-gray-800',
|
||||
bidding: 'bg-blue-100 text-blue-800',
|
||||
closed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
awarded: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
// 상태 한글 라벨
|
||||
export const STATUS_LABELS: Record<SiteBriefingStatus, string> = {
|
||||
scheduled: '입찰예정',
|
||||
ongoing: '입찰',
|
||||
completed: '마감',
|
||||
cancelled: '유찰',
|
||||
postponed: '현장설명회 유찰',
|
||||
};
|
||||
|
||||
export const BID_STATUS_LABELS: Record<BidStatus, string> = {
|
||||
pending: '대기',
|
||||
bidding: '입찰중',
|
||||
closed: '마감',
|
||||
failed: '유찰',
|
||||
awarded: '낙찰',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 폼 관련 타입 및 옵션
|
||||
// ============================================
|
||||
|
||||
// 구분 (온라인/오프라인)
|
||||
export type BriefingType = 'online' | 'offline';
|
||||
|
||||
// 참석 상태
|
||||
export type AttendanceStatus = 'scheduled' | 'attended' | 'absent';
|
||||
|
||||
// 부가세 타입
|
||||
export type VatType = 'excluded' | 'included';
|
||||
|
||||
// 참여업체 메모
|
||||
export interface ParticipatingCompany {
|
||||
id: string;
|
||||
companyName: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 현장설명회 자료 (파일)
|
||||
export interface BriefingDocument {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 폼 데이터 타입
|
||||
export interface SiteBriefingFormData {
|
||||
// 기본 정보
|
||||
briefingCode: string; // 현설번호
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
briefingDate: string; // 현장설명회 일자
|
||||
briefingTime: string; // 현장설명회 시간
|
||||
briefingType: BriefingType; // 구분 (온라인/오프라인)
|
||||
location: string; // 현장설명회 장소
|
||||
attendee: string; // 참석자
|
||||
attendanceStatus: AttendanceStatus; // 상태
|
||||
|
||||
// 입찰 정보
|
||||
projectName: string; // 현장명
|
||||
bidDate: string; // 입찰일자
|
||||
siteCount: number; // 개소
|
||||
constructionStartDate: string; // 공사기간 시작
|
||||
constructionEndDate: string; // 공사기간 종료
|
||||
vatType: VatType; // 부가세
|
||||
workReport: string; // 업무 보고
|
||||
documents: BriefingDocument[]; // 현장설명회 자료
|
||||
|
||||
// 참여업체 및 메모
|
||||
participatingCompanies: ParticipatingCompany[];
|
||||
}
|
||||
|
||||
// 구분 옵션
|
||||
export const BRIEFING_TYPE_OPTIONS = [
|
||||
{ value: 'online', label: '온라인' },
|
||||
{ value: 'offline', label: '오프라인' },
|
||||
];
|
||||
|
||||
// 참석 상태 옵션
|
||||
export const ATTENDANCE_STATUS_OPTIONS = [
|
||||
{ value: 'scheduled', label: '참석예정' },
|
||||
{ value: 'attended', label: '참석완료' },
|
||||
{ value: 'absent', label: '불참' },
|
||||
];
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
];
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptySiteBriefingFormData(): SiteBriefingFormData {
|
||||
return {
|
||||
briefingCode: '',
|
||||
partnerId: '',
|
||||
partnerName: '',
|
||||
briefingDate: '',
|
||||
briefingTime: '',
|
||||
briefingType: 'offline',
|
||||
location: '',
|
||||
attendee: '',
|
||||
attendanceStatus: 'scheduled',
|
||||
projectName: '',
|
||||
bidDate: '',
|
||||
siteCount: 0,
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
vatType: 'excluded',
|
||||
workReport: '',
|
||||
documents: [],
|
||||
participatingCompanies: [],
|
||||
};
|
||||
}
|
||||
|
||||
// SiteBriefing을 FormData로 변환
|
||||
export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingFormData {
|
||||
return {
|
||||
briefingCode: briefing.briefingCode,
|
||||
partnerId: briefing.partnerId,
|
||||
partnerName: briefing.partnerName,
|
||||
briefingDate: briefing.briefingDate,
|
||||
briefingTime: briefing.briefingTime,
|
||||
briefingType: 'offline', // 기본값
|
||||
location: briefing.location,
|
||||
attendee: '', // 기본값
|
||||
attendanceStatus: 'scheduled', // 기본값
|
||||
projectName: briefing.title,
|
||||
bidDate: briefing.bidDate || '',
|
||||
siteCount: 0, // 기본값
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
vatType: 'excluded',
|
||||
workReport: briefing.description,
|
||||
documents: [],
|
||||
participatingCompanies: [],
|
||||
};
|
||||
}
|
||||
169
src/components/common/NoticePopupModal/NoticePopupModal.tsx
Normal file
169
src/components/common/NoticePopupModal/NoticePopupModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface NoticePopupData {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface NoticePopupModalProps {
|
||||
popup: NoticePopupData;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// localStorage 유틸리티
|
||||
// ============================================
|
||||
|
||||
const POPUP_DISMISS_PREFIX = 'popup_dismissed_';
|
||||
|
||||
/**
|
||||
* 자정까지의 타임스탬프 계산
|
||||
*/
|
||||
function getMidnightTimestamp(): number {
|
||||
const now = new Date();
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
|
||||
return midnight.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업이 오늘 하루 동안 숨김 상태인지 확인
|
||||
*/
|
||||
export function isPopupDismissedForToday(popupId: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const dismissedUntil = localStorage.getItem(`${POPUP_DISMISS_PREFIX}${popupId}`);
|
||||
if (!dismissedUntil) return false;
|
||||
|
||||
const dismissedTimestamp = parseInt(dismissedUntil, 10);
|
||||
return Date.now() < dismissedTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업을 오늘 하루 동안 숨김 처리
|
||||
*/
|
||||
function dismissPopupForToday(popupId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const midnightTimestamp = getMidnightTimestamp();
|
||||
localStorage.setItem(`${POPUP_DISMISS_PREFIX}${popupId}`, String(midnightTimestamp));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModalProps) {
|
||||
const [dontShowToday, setDontShowToday] = React.useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowToday) {
|
||||
dismissPopupForToday(popup.id);
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* 오버레이 */}
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 팝업 컨텐츠 */}
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-full max-w-md bg-background rounded-lg shadow-lg overflow-hidden',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'duration-200'
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-muted px-4 py-3 border-b">
|
||||
<DialogPrimitive.Title className="text-base font-medium text-center">
|
||||
알림
|
||||
</DialogPrimitive.Title>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-6">
|
||||
{/* 제목 */}
|
||||
<h3 className="text-base font-medium mb-4">{popup.title}</h3>
|
||||
|
||||
{/* 이미지 영역 */}
|
||||
{popup.imageUrl ? (
|
||||
<div className="relative w-full aspect-[4/3] mb-4 rounded-md overflow-hidden border bg-muted">
|
||||
<img
|
||||
src={popup.imageUrl}
|
||||
alt={popup.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full aspect-[4/3] mb-4 rounded-md border bg-muted flex items-center justify-center">
|
||||
<span className="text-muted-foreground text-sm">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="text-sm text-foreground mb-6">
|
||||
<p className="text-muted-foreground mb-2">내용</p>
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: popup.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t mb-4" />
|
||||
|
||||
{/* 하단 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 체크박스 */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={dontShowToday}
|
||||
onCheckedChange={(checked) => setDontShowToday(checked === true)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
1일간 이 창을 열지 않음
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 닫기 버튼 */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
className="px-6"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
2
src/components/common/NoticePopupModal/index.ts
Normal file
2
src/components/common/NoticePopupModal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { NoticePopupModal, isPopupDismissedForToday } from './NoticePopupModal';
|
||||
export type { NoticePopupData } from './NoticePopupModal';
|
||||
@@ -71,8 +71,8 @@ export interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 시스템 게시판 코드
|
||||
export type SystemBoardCode = 'notice' | 'faq' | 'qna' | 'event' | 'popup';
|
||||
// 시스템 게시판 코드 (시스템 게시판 + 동적 테넌트 게시판)
|
||||
export type SystemBoardCode = 'notice' | 'faq' | 'qna' | 'event' | 'popup' | string;
|
||||
|
||||
// 게시글 필터
|
||||
export interface PostFilters {
|
||||
|
||||
@@ -12,6 +12,8 @@ interface StatCardData {
|
||||
value: string;
|
||||
isPositive: boolean;
|
||||
};
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface StatCardsProps {
|
||||
@@ -23,8 +25,18 @@ export function StatCards({ stats }: StatCardsProps) {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isClickable = !!stat.onClick;
|
||||
|
||||
return (
|
||||
<Card key={index}>
|
||||
<Card
|
||||
key={index}
|
||||
className={`transition-colors ${
|
||||
isClickable ? 'cursor-pointer hover:border-primary/50' : ''
|
||||
} ${
|
||||
stat.isActive ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={stat.onClick}
|
||||
>
|
||||
<CardContent className="p-3 md:p-4 lg:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface StatCard {
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface VersionHistoryItem {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user