feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장
- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,4 +93,37 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*2025-11-27 작성*
|
## 공통 UI 컴포넌트 사용 규칙
|
||||||
|
|
||||||
|
### 로딩 스피너
|
||||||
|
|
||||||
|
**필수**: 로딩 상태 표시 시 반드시 공통 스피너 컴포넌트 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
ContentLoadingSpinner,
|
||||||
|
PageLoadingSpinner,
|
||||||
|
TableLoadingSpinner,
|
||||||
|
ButtonSpinner
|
||||||
|
} from '@/components/ui/loading-spinner';
|
||||||
|
```
|
||||||
|
|
||||||
|
| 컴포넌트 | 용도 | 예시 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `ContentLoadingSpinner` | 상세/수정 페이지 컨텐츠 영역 | `if (isLoading) return <ContentLoadingSpinner />;` |
|
||||||
|
| `PageLoadingSpinner` | 페이지 전환, 전체 페이지 | loading.tsx, 초기 로딩 |
|
||||||
|
| `TableLoadingSpinner` | 테이블/리스트 영역 | 데이터 테이블 로딩 |
|
||||||
|
| `ButtonSpinner` | 버튼 내부 (저장 중 등) | `{isSaving && <ButtonSpinner />}` |
|
||||||
|
|
||||||
|
**금지 패턴:**
|
||||||
|
```tsx
|
||||||
|
// ❌ 텍스트만 사용 금지
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
|
||||||
|
// ❌ 직접 스피너 구현 금지
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*2025-11-27 작성 / 2026-01-12 스피너 규칙 추가*
|
||||||
|
|||||||
52
claudedocs/[IMPL-2026-01-12] project-detail-checklist.md
Normal file
52
claudedocs/[IMPL-2026-01-12] project-detail-checklist.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 프로젝트 실행관리 상세 페이지 구현 체크리스트
|
||||||
|
|
||||||
|
## 구현 일자: 2026-01-12
|
||||||
|
|
||||||
|
## 페이지 구조
|
||||||
|
- 페이지 경로: `/construction/project/management/[id]`
|
||||||
|
- 칸반 보드 형태의 상세 페이지
|
||||||
|
- 프로젝트 → 단계 → 상세 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 목록
|
||||||
|
|
||||||
|
### 1. 타입 및 데이터 준비
|
||||||
|
- [x] types.ts - 상세 페이지용 타입 추가 (Stage, StageDetail, ProjectDetail 등)
|
||||||
|
- [x] actions.ts - 상세 페이지 목업 데이터 추가
|
||||||
|
|
||||||
|
### 2. 칸반 보드 컴포넌트
|
||||||
|
- [x] ProjectKanbanBoard.tsx - 칸반 보드 컨테이너
|
||||||
|
- [x] KanbanColumn.tsx - 칸반 컬럼 공통 컴포넌트
|
||||||
|
- [x] ProjectCard.tsx - 프로젝트 카드 (진행률, 계약금, 기간)
|
||||||
|
- [x] StageCard.tsx - 단계 카드 (입찰/계약/시공)
|
||||||
|
- [x] DetailCard.tsx - 상세 카드 (현장설명회 등 단순 목록)
|
||||||
|
|
||||||
|
### 3. 프로젝트 종료 팝업
|
||||||
|
- [x] ProjectEndDialog.tsx - 프로젝트 종료 다이얼로그
|
||||||
|
|
||||||
|
### 4. 메인 페이지 조립
|
||||||
|
- [x] ProjectDetailClient.tsx - 메인 클라이언트 컴포넌트
|
||||||
|
- [x] page.tsx - 상세 페이지 진입점
|
||||||
|
|
||||||
|
### 5. 검증
|
||||||
|
- [ ] 칸반 보드 동작 확인 (프로젝트→단계→상세 연동)
|
||||||
|
- [ ] 프로젝트 종료 팝업 동작 확인
|
||||||
|
- [ ] 리스트 페이지에서 상세 페이지 이동 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 사항
|
||||||
|
- 1차 구현: 상세 하위 목록 없는 경우 (현장설명회) 먼저 구현
|
||||||
|
- 이후 추가로 보면서 맞춰가기
|
||||||
|
- 기존 리스트 페이지 패턴 참고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 진행 상황
|
||||||
|
- 시작: 2026-01-12
|
||||||
|
- 현재 상태: 1차 구현 완료, 브라우저 검증 대기
|
||||||
|
|
||||||
|
## 테스트 URL
|
||||||
|
- 리스트 페이지: http://localhost:3000/ko/construction/project/management
|
||||||
|
- 상세 페이지: http://localhost:3000/ko/construction/project/management/1
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# 모바일 필터 공통화 마이그레이션 체크리스트
|
||||||
|
|
||||||
|
> **작업 내용**: `IntegratedListTemplateV2` 사용 페이지에 `filterConfig` 방식 모바일 필터 적용
|
||||||
|
> **시작일**: 2026-01-13
|
||||||
|
> **완료 기준**: 모든 테이블 리스트 페이지에서 모바일 바텀시트 필터가 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 이미 완료된 페이지 (6개)
|
||||||
|
|
||||||
|
- [x] 발주관리 (`OrderManagementListClient.tsx`) - filterConfig 방식
|
||||||
|
- [x] 기성청구관리 (`ProgressBillingManagementListClient.tsx`) - filterConfig 방식
|
||||||
|
- [x] 공과관리 (`UtilityManagementListClient.tsx`) - filterConfig 방식
|
||||||
|
- [x] 시공관리 (`ConstructionManagementListClient.tsx`) - filterConfig 방식 ✨변경
|
||||||
|
- [x] 거래처관리 (`PartnerListClient.tsx`) - filterConfig 방식 ✨신규
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 건설 도메인 (12개)
|
||||||
|
|
||||||
|
### 입찰관리
|
||||||
|
- [ ] 현장설명회관리 (`SiteBriefingListClient.tsx`)
|
||||||
|
- [ ] 견적관리 (`EstimateListClient.tsx`)
|
||||||
|
- [ ] 입찰관리 (`BiddingListClient.tsx`)
|
||||||
|
|
||||||
|
### 계약관리
|
||||||
|
- [ ] 계약관리 (`ContractListClient.tsx`)
|
||||||
|
- [ ] 인수인계보고서 (`HandoverReportListClient.tsx`)
|
||||||
|
|
||||||
|
### 발주관리
|
||||||
|
- [ ] 현장관리 (`SiteManagementListClient.tsx`)
|
||||||
|
- [ ] 구조검토관리 (`StructureReviewListClient.tsx`)
|
||||||
|
|
||||||
|
### 공사관리
|
||||||
|
- [ ] 이슈관리 (`IssueManagementListClient.tsx`)
|
||||||
|
- [ ] 작업인력현황 (`WorkerStatusListClient.tsx`)
|
||||||
|
|
||||||
|
### 기준정보
|
||||||
|
- [ ] 품목관리 (`ItemManagementClient.tsx`)
|
||||||
|
- [ ] 단가관리 (`PricingListClient.tsx`)
|
||||||
|
- [ ] 노임관리 (`LaborManagementClient.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 HR 도메인 (5개)
|
||||||
|
|
||||||
|
- [ ] 급여관리 (`hr/SalaryManagement/index.tsx`)
|
||||||
|
- [ ] 사원관리 (`hr/EmployeeManagement/index.tsx`)
|
||||||
|
- [ ] 휴가관리 (`hr/VacationManagement/index.tsx`)
|
||||||
|
- [ ] 근태관리 (`hr/AttendanceManagement/index.tsx`)
|
||||||
|
- [ ] 카드관리 (`hr/CardManagement/index.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 회계 도메인 (14개)
|
||||||
|
|
||||||
|
- [ ] 거래처관리 (`accounting/VendorManagement/index.tsx`)
|
||||||
|
- [ ] 매입관리 (`accounting/PurchaseManagement/index.tsx`)
|
||||||
|
- [ ] 매출관리 (`accounting/SalesManagement/index.tsx`)
|
||||||
|
- [ ] 입금관리 (`accounting/DepositManagement/index.tsx`)
|
||||||
|
- [ ] 출금관리 (`accounting/WithdrawalManagement/index.tsx`)
|
||||||
|
- [ ] 어음관리 (`accounting/BillManagement/index.tsx`)
|
||||||
|
- [ ] 거래처원장 (`accounting/VendorLedger/index.tsx`)
|
||||||
|
- [ ] 지출예상내역서 (`accounting/ExpectedExpenseManagement/index.tsx`)
|
||||||
|
- [ ] 입출금계좌조회 (`accounting/BankTransactionInquiry/index.tsx`)
|
||||||
|
- [ ] 카드내역조회 (`accounting/CardTransactionInquiry/index.tsx`)
|
||||||
|
- [ ] 악성채권추심 (`accounting/BadDebtCollection/index.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 생산/자재/품질/출고 도메인 (6개)
|
||||||
|
|
||||||
|
- [ ] 작업지시관리 (`production/WorkOrders/WorkOrderList.tsx`)
|
||||||
|
- [ ] 작업실적조회 (`production/WorkResults/WorkResultList.tsx`)
|
||||||
|
- [ ] 재고현황 (`material/StockStatus/StockStatusList.tsx`)
|
||||||
|
- [ ] 입고관리 (`material/ReceivingManagement/ReceivingList.tsx`)
|
||||||
|
- [ ] 검사관리 (`quality/InspectionManagement/InspectionList.tsx`)
|
||||||
|
- [ ] 출하관리 (`outbound/ShipmentManagement/ShipmentList.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전자결재 도메인 (3개)
|
||||||
|
|
||||||
|
- [ ] 기안함 (`approval/DraftBox/index.tsx`)
|
||||||
|
- [ ] 결재함 (`approval/ApprovalBox/index.tsx`)
|
||||||
|
- [ ] 참조함 (`approval/ReferenceBox/index.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 설정 도메인 (4개)
|
||||||
|
|
||||||
|
- [ ] 계좌관리 (`settings/AccountManagement/index.tsx`)
|
||||||
|
- [ ] 팝업관리 (`settings/PopupManagement/PopupList.tsx`)
|
||||||
|
- [ ] 결제내역 (`settings/PaymentHistoryManagement/index.tsx`)
|
||||||
|
- [ ] 권한관리 (`settings/PermissionManagement/index.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 기타 도메인 (9개)
|
||||||
|
|
||||||
|
- [ ] 품목기준관리 (`items/ItemListClient.tsx`)
|
||||||
|
- [ ] 견적관리 (`quotes/QuoteManagementClient.tsx`)
|
||||||
|
- [ ] 단가관리-일반 (`pricing/PricingListClient.tsx`)
|
||||||
|
- [ ] 공정관리 (`process-management/ProcessListClient.tsx`)
|
||||||
|
- [ ] 게시판목록 (`board/BoardList/index.tsx`)
|
||||||
|
- [ ] 게시판관리 (`board/BoardManagement/index.tsx`)
|
||||||
|
- [ ] 공지사항 (`customer-center/NoticeManagement/NoticeList.tsx`)
|
||||||
|
- [ ] 이벤트 (`customer-center/EventManagement/EventList.tsx`)
|
||||||
|
- [ ] 1:1문의 (`customer-center/InquiryManagement/InquiryList.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 진행 현황
|
||||||
|
|
||||||
|
| 도메인 | 완료 | 전체 | 진행률 |
|
||||||
|
|--------|------|------|--------|
|
||||||
|
| 건설 (완료) | 6 | 6 | 100% |
|
||||||
|
| 건설 (미완료) | 0 | 12 | 0% |
|
||||||
|
| HR | 0 | 5 | 0% |
|
||||||
|
| 회계 | 0 | 11 | 0% |
|
||||||
|
| 생산/자재/품질/출고 | 0 | 6 | 0% |
|
||||||
|
| 전자결재 | 0 | 3 | 0% |
|
||||||
|
| 설정 | 0 | 4 | 0% |
|
||||||
|
| 기타 | 0 | 9 | 0% |
|
||||||
|
| **총계** | **6** | **56** | **11%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 방법
|
||||||
|
|
||||||
|
각 페이지에 다음 패턴으로 `filterConfig` 추가:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. filterConfig 정의
|
||||||
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{ key: 'field1', label: '필드1', type: 'multi', options: field1Options },
|
||||||
|
{ key: 'field2', label: '필드2', type: 'single', options: field2Options },
|
||||||
|
], [field1Options, field2Options]);
|
||||||
|
|
||||||
|
// 2. filterValues 객체
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
field1: field1Filters,
|
||||||
|
field2: field2Filter,
|
||||||
|
}), [field1Filters, field2Filter]);
|
||||||
|
|
||||||
|
// 3. handleFilterChange 함수
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'field1': setField1Filters(value as string[]); break;
|
||||||
|
case 'field2': setField2Filter(value as string); break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 4. handleFilterReset 함수
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setField1Filters([]);
|
||||||
|
setField2Filter('all');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 5. IntegratedListTemplateV2에 props 전달
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="페이지명 필터"
|
||||||
|
// ... 기존 props
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 작업 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-01-13 | 체크리스트 문서 생성, MobileFilter 스크롤 버그 수정 |
|
||||||
|
| 2026-01-13 | 시공관리 mobileFilterSlot → filterConfig 방식으로 변경, 협력업체관리 filterConfig 적용 |
|
||||||
@@ -34,6 +34,19 @@ Last Updated: 2026-01-12
|
|||||||
| **구조검토관리** | `/ko/construction/order/structure-review` | 🆕 NEW |
|
| **구조검토관리** | `/ko/construction/order/structure-review` | 🆕 NEW |
|
||||||
| **발주관리** | `/ko/construction/order/order-management` | 🆕 NEW |
|
| **발주관리** | `/ko/construction/order/order-management` | 🆕 NEW |
|
||||||
|
|
||||||
|
### 공사관리 (Construction)
|
||||||
|
| 페이지 | URL | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| **시공관리** | `/ko/construction/project/construction-management` | ✅ 완료 |
|
||||||
|
| **이슈관리** | `/ko/construction/project/issue-management` | ✅ 완료 |
|
||||||
|
| **공과관리** | `/ko/construction/project/utility-management` | 🆕 NEW |
|
||||||
|
| **작업인력현황** | `/ko/construction/project/worker-status` | ✅ 완료 |
|
||||||
|
|
||||||
|
### 기성청구관리 (Billing)
|
||||||
|
| 페이지 | URL | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| **기성청구관리** | `/ko/construction/billing/progress-billing-management` | 🆕 NEW |
|
||||||
|
|
||||||
### 기준정보 (Base Info) - 발주관리 하위
|
### 기준정보 (Base Info) - 발주관리 하위
|
||||||
| 페이지 | URL | 상태 |
|
| 페이지 | URL | 상태 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -41,6 +54,3 @@ Last Updated: 2026-01-12
|
|||||||
| **품목관리** | `/ko/construction/order/base-info/items` | 🆕 NEW |
|
| **품목관리** | `/ko/construction/order/base-info/items` | 🆕 NEW |
|
||||||
| **단가관리** | `/ko/construction/order/base-info/pricing` | 🆕 NEW |
|
| **단가관리** | `/ko/construction/order/base-info/pricing` | 🆕 NEW |
|
||||||
| **노임관리** | `/ko/construction/order/base-info/labor` | 🆕 NEW |
|
| **노임관리** | `/ko/construction/order/base-info/labor` | 🆕 NEW |
|
||||||
|
|
||||||
## 공사 관리 (Construction)
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'placehold.co',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb', // 이미지 업로드를 위한 제한 증가
|
bodySizeLimit: '10mb', // 이미지 업로드를 위한 제한 증가
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
|
||||||
|
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
|
||||||
|
|
||||||
|
interface ProgressBillingEditPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBillingEditPage({ params }: ProgressBillingEditPageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getProgressBillingDetail(id)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setData(result.data);
|
||||||
|
} else {
|
||||||
|
setError('기성청구 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||||
|
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
뒤로 가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProgressBillingDetailForm mode="edit" billingId={id} initialData={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing';
|
||||||
|
import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions';
|
||||||
|
|
||||||
|
interface ProgressBillingDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBillingDetailPage({ params }: ProgressBillingDetailPageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getProgressBillingDetail(id)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setData(result.data);
|
||||||
|
} else {
|
||||||
|
setError('기성청구 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||||
|
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
뒤로 가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProgressBillingDetailForm mode="view" billingId={id} initialData={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ProgressBillingManagementListClient from '@/components/business/construction/progress-billing/ProgressBillingManagementListClient';
|
||||||
|
import { getProgressBillingList, getProgressBillingStats } from '@/components/business/construction/progress-billing/actions';
|
||||||
|
import type { ProgressBilling, ProgressBillingStats } from '@/components/business/construction/progress-billing/types';
|
||||||
|
|
||||||
|
export default function ProgressBillingManagementPage() {
|
||||||
|
const [data, setData] = useState<ProgressBilling[]>([]);
|
||||||
|
const [stats, setStats] = useState<ProgressBillingStats | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
getProgressBillingList({ size: 1000 }),
|
||||||
|
getProgressBillingStats(),
|
||||||
|
])
|
||||||
|
.then(([listResult, statsResult]) => {
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setData(listResult.data.items);
|
||||||
|
}
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
setStats(statsResult.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProgressBillingManagementListClient initialData={data} initialStats={stats} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use } from 'react';
|
||||||
|
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstructionManagementEditPage({ params }: PageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
|
||||||
|
return <ConstructionDetailClient id={id} mode="edit" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use } from 'react';
|
||||||
|
import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstructionManagementDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
|
||||||
|
return <ConstructionDetailClient id={id} mode="view" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ConstructionManagementListClient from '@/components/business/construction/management/ConstructionManagementListClient';
|
||||||
|
import {
|
||||||
|
getConstructionManagementList,
|
||||||
|
getConstructionManagementStats,
|
||||||
|
} from '@/components/business/construction/management/actions';
|
||||||
|
import type {
|
||||||
|
ConstructionManagement,
|
||||||
|
ConstructionManagementStats,
|
||||||
|
} from '@/components/business/construction/management/types';
|
||||||
|
|
||||||
|
export default function ConstructionManagementPage() {
|
||||||
|
const [data, setData] = useState<ConstructionManagement[]>([]);
|
||||||
|
const [stats, setStats] = useState<ConstructionManagementStats | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getConstructionManagementList({ size: 1000 }),
|
||||||
|
getConstructionManagementStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setData(listResult.data.items);
|
||||||
|
}
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
setStats(statsResult.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load construction management data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ConstructionManagementListClient initialData={data} initialStats={stats} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||||
|
import { getIssue } from '@/components/business/construction/issue-management/actions';
|
||||||
|
import type { Issue } from '@/components/business/construction/issue-management/types';
|
||||||
|
|
||||||
|
export default function IssueEditPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [issue, setIssue] = useState<Issue | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getIssue(id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setIssue(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('이슈 조회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IssueDetailForm issue={issue} mode="edit" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||||
|
import { getIssue } from '@/components/business/construction/issue-management/actions';
|
||||||
|
import type { Issue } from '@/components/business/construction/issue-management/types';
|
||||||
|
|
||||||
|
export default function IssueDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [issue, setIssue] = useState<Issue | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getIssue(id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setIssue(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('이슈 조회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IssueDetailForm issue={issue} mode="view" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||||
|
|
||||||
|
export default function IssueNewPage() {
|
||||||
|
return <IssueDetailForm mode="create" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import IssueManagementListClient from '@/components/business/construction/issue-management/IssueManagementListClient';
|
||||||
|
import {
|
||||||
|
getIssueList,
|
||||||
|
getIssueStats,
|
||||||
|
} from '@/components/business/construction/issue-management/actions';
|
||||||
|
import type {
|
||||||
|
Issue,
|
||||||
|
IssueStats,
|
||||||
|
} from '@/components/business/construction/issue-management/types';
|
||||||
|
|
||||||
|
export default function IssueManagementPage() {
|
||||||
|
const [data, setData] = useState<Issue[]>([]);
|
||||||
|
const [stats, setStats] = useState<IssueStats | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getIssueList({ size: 1000 }),
|
||||||
|
getIssueStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setData(listResult.data.items);
|
||||||
|
}
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
setStats(statsResult.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load issue management data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IssueManagementListClient initialData={data} initialStats={stats} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use } from 'react';
|
||||||
|
import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
locale: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = use(params);
|
||||||
|
|
||||||
|
return <ProjectDetailClient projectId={id} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
|
||||||
|
|
||||||
|
export default function UtilityManagementPage() {
|
||||||
|
return <UtilityManagementListClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import WorkerStatusListClient from '@/components/business/construction/worker-status/WorkerStatusListClient';
|
||||||
|
import { getWorkerStatusList, getWorkerStatusStats } from '@/components/business/construction/worker-status/actions';
|
||||||
|
import type { WorkerStatus, WorkerStatusStats } from '@/components/business/construction/worker-status/types';
|
||||||
|
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
|
|
||||||
|
export default function WorkerStatusPage() {
|
||||||
|
const [data, setData] = useState<WorkerStatus[]>([]);
|
||||||
|
const [stats, setStats] = useState<WorkerStatusStats | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getWorkerStatusList(), getWorkerStatusStats()])
|
||||||
|
.then(([listResult, statsResult]) => {
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setData(listResult.data.items);
|
||||||
|
}
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
setStats(statsResult.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ContentLoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WorkerStatusListClient initialData={data} initialStats={stats} />;
|
||||||
|
}
|
||||||
@@ -284,6 +284,25 @@
|
|||||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bell ringing animation for notifications */
|
||||||
|
@keyframes bell-ring {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
10% { transform: rotate(14deg); }
|
||||||
|
20% { transform: rotate(-12deg); }
|
||||||
|
30% { transform: rotate(10deg); }
|
||||||
|
40% { transform: rotate(-8deg); }
|
||||||
|
50% { transform: rotate(6deg); }
|
||||||
|
60% { transform: rotate(-4deg); }
|
||||||
|
70% { transform: rotate(2deg); }
|
||||||
|
80% { transform: rotate(-1deg); }
|
||||||
|
90% { transform: rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-bell-ring {
|
||||||
|
animation: bell-ring 1s ease-in-out infinite;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
|||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
import {
|
import {
|
||||||
TodayIssueSection,
|
TodayIssueSection,
|
||||||
|
StatusBoardSection,
|
||||||
DailyReportSection,
|
DailyReportSection,
|
||||||
MonthlyExpenseSection,
|
MonthlyExpenseSection,
|
||||||
CardManagementSection,
|
CardManagementSection,
|
||||||
@@ -214,12 +215,9 @@ export function CEODashboard() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 오늘의 이슈 */}
|
{/* 오늘의 이슈 (새 리스트 형태) */}
|
||||||
{dashboardSettings.todayIssue.enabled && (
|
{dashboardSettings.todayIssueList && (
|
||||||
<TodayIssueSection
|
<TodayIssueSection items={data.todayIssueList} />
|
||||||
items={data.todayIssue}
|
|
||||||
itemSettings={dashboardSettings.todayIssue.items}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 일일 일보 */}
|
{/* 일일 일보 */}
|
||||||
@@ -230,6 +228,14 @@ export function CEODashboard() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||||
|
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
|
||||||
|
<StatusBoardSection
|
||||||
|
items={data.todayIssue}
|
||||||
|
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 당월 예상 지출 내역 */}
|
{/* 당월 예상 지출 내역 */}
|
||||||
{dashboardSettings.monthlyExpense && (
|
{dashboardSettings.monthlyExpense && (
|
||||||
<MonthlyExpenseSection
|
<MonthlyExpenseSection
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
|
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
|
||||||
|
|
||||||
// 오늘의 이슈 항목 라벨
|
// 현황판 항목 라벨 (구 오늘의 이슈)
|
||||||
const TODAY_ISSUE_LABELS: Record<keyof TodayIssueSettings, string> = {
|
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||||
orders: '수주',
|
orders: '수주',
|
||||||
debtCollection: '채권 추심',
|
debtCollection: '채권 추심',
|
||||||
safetyStock: '안전 재고',
|
safetyStock: '안전 재고',
|
||||||
@@ -83,37 +83,67 @@ export function DashboardSettingsDialog({
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 오늘의 이슈 전체 토글
|
// 오늘의 이슈 (리스트 형태) 토글
|
||||||
const handleTodayIssueToggle = useCallback((enabled: boolean) => {
|
const handleTodayIssueListToggle = useCallback((enabled: boolean) => {
|
||||||
setLocalSettings((prev) => ({
|
setLocalSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
todayIssue: {
|
todayIssueList: enabled,
|
||||||
...prev.todayIssue,
|
|
||||||
enabled,
|
|
||||||
// 전체 OFF 시 개별 항목도 모두 OFF
|
|
||||||
items: enabled
|
|
||||||
? prev.todayIssue.items
|
|
||||||
: Object.keys(prev.todayIssue.items).reduce(
|
|
||||||
(acc, key) => ({ ...acc, [key]: false }),
|
|
||||||
{} as TodayIssueSettings
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 오늘의 이슈 개별 항목 토글
|
// 현황판 전체 토글 (구 오늘의 이슈)
|
||||||
const handleTodayIssueItemToggle = useCallback(
|
const handleStatusBoardToggle = useCallback((enabled: boolean) => {
|
||||||
(key: keyof TodayIssueSettings, enabled: boolean) => {
|
setLocalSettings((prev) => {
|
||||||
setLocalSettings((prev) => ({
|
const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items;
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
todayIssue: {
|
statusBoard: {
|
||||||
...prev.todayIssue,
|
enabled,
|
||||||
items: {
|
// 전체 OFF 시 개별 항목도 모두 OFF
|
||||||
...prev.todayIssue.items,
|
items: enabled
|
||||||
[key]: enabled,
|
? statusBoardItems
|
||||||
},
|
: Object.keys(statusBoardItems).reduce(
|
||||||
|
(acc, key) => ({ ...acc, [key]: false }),
|
||||||
|
{} as TodayIssueSettings
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
// Legacy 호환성 유지
|
||||||
|
todayIssue: {
|
||||||
|
enabled,
|
||||||
|
items: enabled
|
||||||
|
? statusBoardItems
|
||||||
|
: Object.keys(statusBoardItems).reduce(
|
||||||
|
(acc, key) => ({ ...acc, [key]: false }),
|
||||||
|
{} as TodayIssueSettings
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 현황판 개별 항목 토글
|
||||||
|
const handleStatusBoardItemToggle = useCallback(
|
||||||
|
(key: keyof TodayIssueSettings, enabled: boolean) => {
|
||||||
|
setLocalSettings((prev) => {
|
||||||
|
const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items;
|
||||||
|
const newItems = {
|
||||||
|
...statusBoardItems,
|
||||||
|
[key]: enabled,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
statusBoard: {
|
||||||
|
...prev.statusBoard,
|
||||||
|
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
|
||||||
|
items: newItems,
|
||||||
|
},
|
||||||
|
// Legacy 호환성 유지
|
||||||
|
todayIssue: {
|
||||||
|
...prev.todayIssue,
|
||||||
|
items: newItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -280,30 +310,44 @@ export function DashboardSettingsDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{/* 오늘의 이슈 섹션 */}
|
{/* 오늘의 이슈 (리스트 형태) */}
|
||||||
|
<SectionRow
|
||||||
|
label="오늘의 이슈"
|
||||||
|
checked={localSettings.todayIssueList}
|
||||||
|
onCheckedChange={handleTodayIssueListToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 일일 일보 */}
|
||||||
|
<SectionRow
|
||||||
|
label="일일 일보"
|
||||||
|
checked={localSettings.dailyReport}
|
||||||
|
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||||
<div className="space-y-0 rounded-lg overflow-hidden">
|
<div className="space-y-0 rounded-lg overflow-hidden">
|
||||||
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
|
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
|
||||||
<span className="text-sm font-medium text-gray-800">오늘의 이슈</span>
|
<span className="text-sm font-medium text-gray-800">현황판</span>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={localSettings.todayIssue.enabled}
|
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
|
||||||
onCheckedChange={handleTodayIssueToggle}
|
onCheckedChange={handleStatusBoardToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{localSettings.todayIssue.enabled && (
|
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
|
||||||
<div className="bg-gray-50">
|
<div className="bg-gray-50">
|
||||||
{(Object.keys(TODAY_ISSUE_LABELS) as Array<keyof TodayIssueSettings>).map(
|
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||||
(key) => (
|
(key) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
|
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{TODAY_ISSUE_LABELS[key]}
|
{STATUS_BOARD_LABELS[key]}
|
||||||
</span>
|
</span>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={localSettings.todayIssue.items[key]}
|
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
handleTodayIssueItemToggle(key, checked)
|
handleStatusBoardItemToggle(key, checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 일일 일보 */}
|
|
||||||
<SectionRow
|
|
||||||
label="일일 일보"
|
|
||||||
checked={localSettings.dailyReport}
|
|
||||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 당월 예상 지출 내역 */}
|
{/* 당월 예상 지출 내역 */}
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="당월 예상 지출 내역"
|
label="당월 예상 지출 내역"
|
||||||
|
|||||||
@@ -15,6 +15,128 @@ export const mockData: CEODashboardData = {
|
|||||||
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
|
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
|
||||||
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
|
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
|
||||||
],
|
],
|
||||||
|
todayIssueList: [
|
||||||
|
{
|
||||||
|
id: 'til1',
|
||||||
|
badge: '수주 성공',
|
||||||
|
content: 'A전자 신규 수주 450,000,000원 확정',
|
||||||
|
time: '10분 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/sales/order-management-sales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til2',
|
||||||
|
badge: '주식 이슈',
|
||||||
|
content: 'B물산 미수금 15,000,000원 연체 15일',
|
||||||
|
time: '1시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/accounting/receivables-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til3',
|
||||||
|
badge: '직정 제고',
|
||||||
|
content: '원자재 3종 안전재고 미달',
|
||||||
|
time: '20시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/material/stock-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til4',
|
||||||
|
badge: '지출예상내역서',
|
||||||
|
content: '품의서명 외 5건 (2,500,000원)',
|
||||||
|
time: '20시간 전',
|
||||||
|
needsApproval: true,
|
||||||
|
path: '/approval/inbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til5',
|
||||||
|
badge: '세금 신고',
|
||||||
|
content: '4분기 부가세 신고 D-15',
|
||||||
|
time: '20시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/accounting/tax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til6',
|
||||||
|
badge: '결재 요청',
|
||||||
|
content: '법인카드 사용 내역 승인 요청 (김철수)',
|
||||||
|
time: '30분 전',
|
||||||
|
needsApproval: true,
|
||||||
|
path: '/approval/inbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til7',
|
||||||
|
badge: '수주 성공',
|
||||||
|
content: 'C건설 추가 발주 120,000,000원 확정',
|
||||||
|
time: '2시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/sales/order-management-sales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til8',
|
||||||
|
badge: '기타',
|
||||||
|
content: '신규 거래처 D산업 등록 완료',
|
||||||
|
time: '3시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/accounting/vendors',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til9',
|
||||||
|
badge: '결재 요청',
|
||||||
|
content: '출장비 정산 승인 요청 (이영희)',
|
||||||
|
time: '4시간 전',
|
||||||
|
needsApproval: true,
|
||||||
|
path: '/approval/inbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til10',
|
||||||
|
badge: '주식 이슈',
|
||||||
|
content: 'E물류 미수금 8,500,000원 연체 7일',
|
||||||
|
time: '5시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/accounting/receivables-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til11',
|
||||||
|
badge: '직정 제고',
|
||||||
|
content: '부품 A-102 재고 부족 경고',
|
||||||
|
time: '6시간 전',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/material/stock-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til12',
|
||||||
|
badge: '지출예상내역서',
|
||||||
|
content: '장비 구매 품의서 (15,000,000원)',
|
||||||
|
time: '8시간 전',
|
||||||
|
needsApproval: true,
|
||||||
|
path: '/approval/inbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til13',
|
||||||
|
badge: '수주 성공',
|
||||||
|
content: 'F테크 유지보수 계약 연장 85,000,000원',
|
||||||
|
time: '어제',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/sales/order-management-sales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til14',
|
||||||
|
badge: '세금 신고',
|
||||||
|
content: '원천세 신고 완료',
|
||||||
|
time: '어제',
|
||||||
|
needsApproval: false,
|
||||||
|
path: '/accounting/tax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'til15',
|
||||||
|
badge: '결재 요청',
|
||||||
|
content: '연차 사용 승인 요청 (박지민 외 2명)',
|
||||||
|
time: '어제',
|
||||||
|
needsApproval: true,
|
||||||
|
path: '/hr/vacation-management',
|
||||||
|
},
|
||||||
|
],
|
||||||
dailyReport: {
|
dailyReport: {
|
||||||
date: '2026년 1월 5일 월요일',
|
date: '2026년 1월 5일 월요일',
|
||||||
cards: [
|
cards: [
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { SectionTitle, IssueCardItem } from '../components';
|
||||||
|
import type { TodayIssueItem, TodayIssueSettings } from '../types';
|
||||||
|
|
||||||
|
// 라벨 → 설정키 매핑
|
||||||
|
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||||
|
'수주': 'orders',
|
||||||
|
'채권 추심': 'debtCollection',
|
||||||
|
'안전 재고': 'safetyStock',
|
||||||
|
'세금 신고': 'taxReport',
|
||||||
|
'신규 업체 등록': 'newVendor',
|
||||||
|
'연차': 'annualLeave',
|
||||||
|
'지각': 'lateness',
|
||||||
|
'결근': 'absence',
|
||||||
|
'발주': 'purchase',
|
||||||
|
'결재 요청': 'approvalRequest',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusBoardSectionProps {
|
||||||
|
items: TodayIssueItem[];
|
||||||
|
itemSettings?: TodayIssueSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleItemClick = (path: string) => {
|
||||||
|
router.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설정에 따라 항목 필터링
|
||||||
|
const filteredItems = itemSettings
|
||||||
|
? items.filter((item) => {
|
||||||
|
const settingKey = LABEL_TO_SETTING_KEY[item.label];
|
||||||
|
return settingKey ? itemSettings[settingKey] : true;
|
||||||
|
})
|
||||||
|
: items;
|
||||||
|
|
||||||
|
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
|
||||||
|
const getGridColsClass = () => {
|
||||||
|
const count = filteredItems.length;
|
||||||
|
if (count <= 1) return 'grid-cols-1';
|
||||||
|
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
|
||||||
|
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
|
||||||
|
// 4개 이상: 최대 4열, 넘치면 아래로
|
||||||
|
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<SectionTitle title="현황판" badge="warning" />
|
||||||
|
|
||||||
|
<div className={`grid ${getGridColsClass()} gap-3`}>
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<IssueCardItem
|
||||||
|
key={item.id}
|
||||||
|
label={item.label}
|
||||||
|
count={item.count}
|
||||||
|
subLabel={item.subLabel}
|
||||||
|
isHighlighted={item.isHighlighted}
|
||||||
|
onClick={() => handleItemClick(item.path)}
|
||||||
|
icon={item.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,71 +1,147 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { SectionTitle, IssueCardItem } from '../components';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { TodayIssueItem, TodayIssueSettings } from '../types';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { TodayIssueListItem, TodayIssueListBadgeType } from '../types';
|
||||||
|
|
||||||
// 라벨 → 설정키 매핑
|
// 뱃지 색상 매핑
|
||||||
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
|
||||||
'수주': 'orders',
|
'수주 성공': 'bg-blue-100 text-blue-700 hover:bg-blue-100',
|
||||||
'채권 추심': 'debtCollection',
|
'주식 이슈': 'bg-purple-100 text-purple-700 hover:bg-purple-100',
|
||||||
'안전 재고': 'safetyStock',
|
'직정 제고': 'bg-orange-100 text-orange-700 hover:bg-orange-100',
|
||||||
'세금 신고': 'taxReport',
|
'지출예상내역서': 'bg-green-100 text-green-700 hover:bg-green-100',
|
||||||
'신규 업체 등록': 'newVendor',
|
'세금 신고': 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||||
'연차': 'annualLeave',
|
'결재 요청': 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100',
|
||||||
'지각': 'lateness',
|
'기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100',
|
||||||
'결근': 'absence',
|
|
||||||
'발주': 'purchase',
|
|
||||||
'결재 요청': 'approvalRequest',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 필터 옵션
|
||||||
|
const FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '수주 성공', label: '수주 성공' },
|
||||||
|
{ value: '주식 이슈', label: '주식 이슈' },
|
||||||
|
{ value: '직정 제고', label: '직정 제고' },
|
||||||
|
{ value: '지출예상내역서', label: '지출예상내역서' },
|
||||||
|
{ value: '세금 신고', label: '세금 신고' },
|
||||||
|
{ value: '결재 요청', label: '결재 요청' },
|
||||||
|
];
|
||||||
|
|
||||||
interface TodayIssueSectionProps {
|
interface TodayIssueSectionProps {
|
||||||
items: TodayIssueItem[];
|
items: TodayIssueListItem[];
|
||||||
itemSettings?: TodayIssueSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProps) {
|
export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [filter, setFilter] = useState<string>('all');
|
||||||
|
|
||||||
const handleItemClick = (path: string) => {
|
// 필터링된 아이템
|
||||||
router.push(path);
|
const filteredItems = filter === 'all'
|
||||||
|
? items
|
||||||
|
: items.filter((item) => item.badge === filter);
|
||||||
|
|
||||||
|
// 아이템 클릭
|
||||||
|
const handleItemClick = (item: TodayIssueListItem) => {
|
||||||
|
if (item.path) {
|
||||||
|
router.push(item.path);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 설정에 따라 항목 필터링
|
// 승인 버튼 클릭
|
||||||
const filteredItems = itemSettings
|
const handleApprove = (item: TodayIssueListItem) => {
|
||||||
? items.filter((item) => {
|
toast.success(`"${item.content}" 승인 처리되었습니다.`);
|
||||||
const settingKey = LABEL_TO_SETTING_KEY[item.label];
|
};
|
||||||
return settingKey ? itemSettings[settingKey] : true;
|
|
||||||
})
|
|
||||||
: items;
|
|
||||||
|
|
||||||
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
|
// 반려 버튼 클릭
|
||||||
const getGridColsClass = () => {
|
const handleReject = (item: TodayIssueListItem) => {
|
||||||
const count = filteredItems.length;
|
toast.error(`"${item.content}" 반려 처리되었습니다.`);
|
||||||
if (count <= 1) return 'grid-cols-1';
|
|
||||||
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
|
|
||||||
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
|
|
||||||
// 4개 이상: 최대 4열, 넘치면 아래로
|
|
||||||
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<SectionTitle title="오늘의 이슈" badge="warning" />
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">오늘의 이슈</h2>
|
||||||
|
<Select value={filter} onValueChange={setFilter}>
|
||||||
|
<SelectTrigger className="w-32 h-9">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`grid ${getGridColsClass()} gap-3`}>
|
{/* 리스트 */}
|
||||||
{filteredItems.map((item) => (
|
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1">
|
||||||
<IssueCardItem
|
{filteredItems.length === 0 ? (
|
||||||
key={item.id}
|
<div className="text-center py-8 text-gray-500">
|
||||||
label={item.label}
|
표시할 이슈가 없습니다.
|
||||||
count={item.count}
|
</div>
|
||||||
subLabel={item.subLabel}
|
) : (
|
||||||
isHighlighted={item.isHighlighted}
|
filteredItems.map((item) => (
|
||||||
onClick={() => handleItemClick(item.path)}
|
<div
|
||||||
icon={item.icon}
|
key={item.id}
|
||||||
/>
|
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
))}
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
{/* 좌측: 뱃지 + 내용 */}
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`shrink-0 ${BADGE_COLORS[item.badge]}`}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-gray-800 truncate">
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 시간 + 버튼 */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-4">
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{item.time}
|
||||||
|
</span>
|
||||||
|
{item.needsApproval && (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-7 px-3 bg-blue-500 hover:bg-blue-600 text-white text-xs"
|
||||||
|
onClick={() => handleApprove(item)}
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
onClick={() => handleReject(item)}
|
||||||
|
>
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { TodayIssueSection } from './TodayIssueSection';
|
export { TodayIssueSection } from './TodayIssueSection';
|
||||||
|
export { StatusBoardSection } from './StatusBoardSection';
|
||||||
export { DailyReportSection } from './DailyReportSection';
|
export { DailyReportSection } from './DailyReportSection';
|
||||||
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
|
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
|
||||||
export { CardManagementSection } from './CardManagementSection';
|
export { CardManagementSection } from './CardManagementSection';
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface AmountCard {
|
|||||||
isHighlighted?: boolean; // 빨간색 강조
|
isHighlighted?: boolean; // 빨간색 강조
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오늘의 이슈 항목
|
// 오늘의 이슈 항목 (카드 형태 - 현황판용)
|
||||||
export interface TodayIssueItem {
|
export interface TodayIssueItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -56,6 +56,26 @@ export interface TodayIssueItem {
|
|||||||
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
|
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오늘의 이슈 뱃지 타입
|
||||||
|
export type TodayIssueListBadgeType =
|
||||||
|
| '수주 성공'
|
||||||
|
| '주식 이슈'
|
||||||
|
| '직정 제고'
|
||||||
|
| '지출예상내역서'
|
||||||
|
| '세금 신고'
|
||||||
|
| '결재 요청'
|
||||||
|
| '기타';
|
||||||
|
|
||||||
|
// 오늘의 이슈 리스트 아이템 (리스트 형태 - 새로운 오늘의 이슈용)
|
||||||
|
export interface TodayIssueListItem {
|
||||||
|
id: string;
|
||||||
|
badge: TodayIssueListBadgeType;
|
||||||
|
content: string;
|
||||||
|
time: string; // "10분 전", "1시간 전" 등
|
||||||
|
needsApproval?: boolean; // 승인/반려 버튼 표시 여부
|
||||||
|
path?: string; // 클릭 시 이동할 경로
|
||||||
|
}
|
||||||
|
|
||||||
// 일일 일보 데이터
|
// 일일 일보 데이터
|
||||||
export interface DailyReportData {
|
export interface DailyReportData {
|
||||||
date: string; // "2026년 1월 5일 월요일"
|
date: string; // "2026년 1월 5일 월요일"
|
||||||
@@ -135,7 +155,8 @@ export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'constructio
|
|||||||
|
|
||||||
// CEO Dashboard 전체 데이터
|
// CEO Dashboard 전체 데이터
|
||||||
export interface CEODashboardData {
|
export interface CEODashboardData {
|
||||||
todayIssue: TodayIssueItem[];
|
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
|
||||||
|
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
|
||||||
dailyReport: DailyReportData;
|
dailyReport: DailyReportData;
|
||||||
monthlyExpense: MonthlyExpenseData;
|
monthlyExpense: MonthlyExpenseData;
|
||||||
cardManagement: CardManagementData;
|
cardManagement: CardManagementData;
|
||||||
@@ -194,8 +215,10 @@ export interface WelfareSettings {
|
|||||||
|
|
||||||
// 대시보드 전체 설정
|
// 대시보드 전체 설정
|
||||||
export interface DashboardSettings {
|
export interface DashboardSettings {
|
||||||
// 오늘의 이슈 섹션
|
// 오늘의 이슈 섹션 (새 리스트 형태)
|
||||||
todayIssue: {
|
todayIssueList: boolean;
|
||||||
|
// 현황판 섹션 (구 오늘의 이슈 - 카드 형태)
|
||||||
|
statusBoard: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
items: TodayIssueSettings;
|
items: TodayIssueSettings;
|
||||||
};
|
};
|
||||||
@@ -212,6 +235,11 @@ export interface DashboardSettings {
|
|||||||
debtCollection: boolean;
|
debtCollection: boolean;
|
||||||
vat: boolean;
|
vat: boolean;
|
||||||
calendar: boolean;
|
calendar: boolean;
|
||||||
|
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
|
||||||
|
todayIssue: {
|
||||||
|
enabled: boolean;
|
||||||
|
items: TodayIssueSettings;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 상세 모달 공통 타입 =====
|
// ===== 상세 모달 공통 타입 =====
|
||||||
@@ -398,7 +426,10 @@ export interface DetailModalConfig {
|
|||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||||
todayIssue: {
|
// 새 오늘의 이슈 (리스트 형태)
|
||||||
|
todayIssueList: true,
|
||||||
|
// 현황판 (구 오늘의 이슈 - 카드 형태)
|
||||||
|
statusBoard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
items: {
|
items: {
|
||||||
orders: true,
|
orders: true,
|
||||||
@@ -436,4 +467,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
|||||||
debtCollection: true,
|
debtCollection: true,
|
||||||
vat: true,
|
vat: true,
|
||||||
calendar: true,
|
calendar: true,
|
||||||
|
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
|
||||||
|
todayIssue: {
|
||||||
|
enabled: true,
|
||||||
|
items: {
|
||||||
|
orders: true,
|
||||||
|
debtCollection: true,
|
||||||
|
safetyStock: true,
|
||||||
|
taxReport: false,
|
||||||
|
newVendor: false,
|
||||||
|
annualLeave: true,
|
||||||
|
lateness: true,
|
||||||
|
absence: false,
|
||||||
|
purchase: false,
|
||||||
|
approvalRequest: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AlertTriangle, List, Mic, X, Undo2, Upload } 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Issue, IssueFormData, IssueImage, IssueStatus, IssueCategory, IssuePriority } from './types';
|
||||||
|
import {
|
||||||
|
ISSUE_STATUS_FORM_OPTIONS,
|
||||||
|
ISSUE_PRIORITY_FORM_OPTIONS,
|
||||||
|
ISSUE_CATEGORY_FORM_OPTIONS,
|
||||||
|
MOCK_CONSTRUCTION_NUMBERS,
|
||||||
|
MOCK_ISSUE_PARTNERS,
|
||||||
|
MOCK_ISSUE_SITES,
|
||||||
|
MOCK_ISSUE_REPORTERS,
|
||||||
|
MOCK_ISSUE_ASSIGNEES,
|
||||||
|
} from './types';
|
||||||
|
import { createIssue, updateIssue, withdrawIssue } from './actions';
|
||||||
|
|
||||||
|
interface IssueDetailFormProps {
|
||||||
|
issue?: Issue;
|
||||||
|
mode?: 'view' | 'edit' | 'create';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isEditMode = mode === 'edit';
|
||||||
|
const isCreateMode = mode === 'create';
|
||||||
|
const isViewMode = mode === 'view';
|
||||||
|
|
||||||
|
// 이미지 업로드 ref
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 철회 다이얼로그
|
||||||
|
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState<IssueFormData>({
|
||||||
|
issueNumber: issue?.issueNumber || '',
|
||||||
|
constructionNumber: issue?.constructionNumber || '',
|
||||||
|
partnerName: issue?.partnerName || '',
|
||||||
|
siteName: issue?.siteName || '',
|
||||||
|
constructionPM: issue?.constructionPM || '',
|
||||||
|
constructionManagers: issue?.constructionManagers || '',
|
||||||
|
reporter: issue?.reporter || '',
|
||||||
|
assignee: issue?.assignee || '',
|
||||||
|
reportDate: issue?.reportDate || new Date().toISOString().split('T')[0],
|
||||||
|
resolvedDate: issue?.resolvedDate || '',
|
||||||
|
status: issue?.status || 'received',
|
||||||
|
category: issue?.category || 'material',
|
||||||
|
priority: issue?.priority || 'normal',
|
||||||
|
title: issue?.title || '',
|
||||||
|
content: issue?.content || '',
|
||||||
|
images: issue?.images || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 시공번호 변경 시 관련 정보 자동 채움
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.constructionNumber) {
|
||||||
|
const construction = MOCK_CONSTRUCTION_NUMBERS.find(
|
||||||
|
(c) => c.value === formData.constructionNumber
|
||||||
|
);
|
||||||
|
if (construction) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
partnerName: construction.partnerName,
|
||||||
|
siteName: construction.siteName,
|
||||||
|
constructionPM: construction.pm,
|
||||||
|
constructionManagers: construction.managers,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formData.constructionNumber]);
|
||||||
|
|
||||||
|
// 담당자 지정 시 상태를 처리중으로 자동 변경
|
||||||
|
const handleAssigneeChange = useCallback((value: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
assignee: value,
|
||||||
|
// 담당자가 지정되고 현재 상태가 '접수'이면 '처리중'으로 변경
|
||||||
|
status: value && prev.status === 'received' ? 'in_progress' : prev.status,
|
||||||
|
}));
|
||||||
|
if (value && formData.status === 'received') {
|
||||||
|
toast.info('담당자가 지정되어 상태가 "처리중"으로 변경되었습니다.');
|
||||||
|
}
|
||||||
|
}, [formData.status]);
|
||||||
|
|
||||||
|
// 중요도 변경 시 긴급이면 알림 표시
|
||||||
|
const handlePriorityChange = useCallback((value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, priority: value as IssuePriority }));
|
||||||
|
if (value === 'urgent') {
|
||||||
|
toast.warning('긴급 이슈로 설정되었습니다. 공사PM과 대표에게 알림이 발송됩니다.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 입력 핸들러
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(field: keyof IssueFormData) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectChange = useCallback((field: keyof IssueFormData) => (value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 버튼 클릭
|
||||||
|
const handleEditClick = useCallback(() => {
|
||||||
|
if (issue?.id) {
|
||||||
|
router.push(`/ko/construction/project/issue-management/${issue.id}/edit`);
|
||||||
|
}
|
||||||
|
}, [router, issue?.id]);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
toast.error('제목을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.constructionNumber) {
|
||||||
|
toast.error('시공번호를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (isCreateMode) {
|
||||||
|
const result = await createIssue({
|
||||||
|
issueNumber: `ISS-${Date.now()}`,
|
||||||
|
constructionNumber: formData.constructionNumber,
|
||||||
|
partnerName: formData.partnerName,
|
||||||
|
siteName: formData.siteName,
|
||||||
|
constructionPM: formData.constructionPM,
|
||||||
|
constructionManagers: formData.constructionManagers,
|
||||||
|
category: formData.category,
|
||||||
|
title: formData.title,
|
||||||
|
content: formData.content,
|
||||||
|
reporter: formData.reporter,
|
||||||
|
reportDate: formData.reportDate,
|
||||||
|
resolvedDate: formData.resolvedDate || null,
|
||||||
|
assignee: formData.assignee,
|
||||||
|
priority: formData.priority,
|
||||||
|
status: formData.status,
|
||||||
|
images: formData.images,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('이슈가 등록되었습니다.');
|
||||||
|
router.push('/ko/construction/project/issue-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '이슈 등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await updateIssue(issue!.id, {
|
||||||
|
constructionNumber: formData.constructionNumber,
|
||||||
|
partnerName: formData.partnerName,
|
||||||
|
siteName: formData.siteName,
|
||||||
|
constructionPM: formData.constructionPM,
|
||||||
|
constructionManagers: formData.constructionManagers,
|
||||||
|
category: formData.category,
|
||||||
|
title: formData.title,
|
||||||
|
content: formData.content,
|
||||||
|
reporter: formData.reporter,
|
||||||
|
reportDate: formData.reportDate,
|
||||||
|
resolvedDate: formData.resolvedDate || null,
|
||||||
|
assignee: formData.assignee,
|
||||||
|
priority: formData.priority,
|
||||||
|
status: formData.status,
|
||||||
|
images: formData.images,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('이슈가 수정되었습니다.');
|
||||||
|
router.push('/ko/construction/project/issue-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '이슈 수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, isCreateMode, issue, router]);
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 철회
|
||||||
|
const handleWithdraw = useCallback(async () => {
|
||||||
|
if (!issue?.id) return;
|
||||||
|
try {
|
||||||
|
const result = await withdrawIssue(issue.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('이슈가 철회되었습니다.');
|
||||||
|
router.push('/ko/construction/project/issue-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '이슈 철회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('이슈 철회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setWithdrawDialogOpen(false);
|
||||||
|
}
|
||||||
|
}, [issue?.id, router]);
|
||||||
|
|
||||||
|
// 이미지 업로드 핸들러
|
||||||
|
const handleImageUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const newImages: IssueImage[] = Array.from(files).map((file, index) => ({
|
||||||
|
id: `img-${Date.now()}-${index}`,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
fileName: file.name,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
images: [...prev.images, ...newImages],
|
||||||
|
}));
|
||||||
|
toast.success(`${files.length}개의 이미지가 추가되었습니다.`);
|
||||||
|
|
||||||
|
// 입력 초기화
|
||||||
|
if (imageInputRef.current) {
|
||||||
|
imageInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이미지 삭제
|
||||||
|
const handleImageRemove = useCallback((imageId: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
images: prev.images.filter((img) => img.id !== imageId),
|
||||||
|
}));
|
||||||
|
toast.success('이미지가 삭제되었습니다.');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 녹음 버튼 (UI만)
|
||||||
|
const handleRecordClick = useCallback(() => {
|
||||||
|
toast.info('녹음 기능은 준비 중입니다.');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 읽기 전용 여부
|
||||||
|
const isReadOnly = isViewMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title={isCreateMode ? '이슈 등록' : '이슈 상세'}
|
||||||
|
description="이슈를 등록하고 관리합니다"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
actions={
|
||||||
|
isViewMode ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/ko/construction/project/issue-management')}
|
||||||
|
>
|
||||||
|
<List className="mr-2 h-4 w-4" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setWithdrawDialogOpen(true)}
|
||||||
|
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
<Undo2 className="mr-2 h-4 w-4" />
|
||||||
|
철회
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditClick}>수정</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/ko/construction/project/issue-management')}
|
||||||
|
>
|
||||||
|
<List className="mr-2 h-4 w-4" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 이슈 정보 카드 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>이슈 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 이슈번호 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="issueNumber">이슈번호</Label>
|
||||||
|
<Input
|
||||||
|
id="issueNumber"
|
||||||
|
value={formData.issueNumber || (isCreateMode ? '자동 생성' : '')}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시공번호 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="constructionNumber">시공번호</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.constructionNumber}
|
||||||
|
onValueChange={handleSelectChange('constructionNumber')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="constructionNumber">
|
||||||
|
<SelectValue placeholder="시공번호 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_CONSTRUCTION_NUMBERS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 거래처 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="partnerName">거래처</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.partnerName}
|
||||||
|
onValueChange={handleSelectChange('partnerName')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="partnerName">
|
||||||
|
<SelectValue placeholder="거래처 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_ISSUE_PARTNERS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="siteName">현장</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.siteName}
|
||||||
|
onValueChange={handleSelectChange('siteName')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="siteName">
|
||||||
|
<SelectValue placeholder="현장 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_ISSUE_SITES.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공사PM (자동) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="constructionPM">공사PM</Label>
|
||||||
|
<Input
|
||||||
|
id="constructionPM"
|
||||||
|
value={formData.constructionPM}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
placeholder="시공번호 선택 시 자동 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공사담당자 (자동) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="constructionManagers">공사담당자</Label>
|
||||||
|
<Input
|
||||||
|
id="constructionManagers"
|
||||||
|
value={formData.constructionManagers}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
placeholder="시공번호 선택 시 자동 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보고자 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reporter">보고자</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.reporter}
|
||||||
|
onValueChange={handleSelectChange('reporter')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="reporter">
|
||||||
|
<SelectValue placeholder="보고자 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_ISSUE_REPORTERS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 담당자 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="assignee">담당자</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.assignee}
|
||||||
|
onValueChange={handleAssigneeChange}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="assignee">
|
||||||
|
<SelectValue placeholder="담당자 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_ISSUE_ASSIGNEES.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이슈보고일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reportDate">이슈보고일</Label>
|
||||||
|
<Input
|
||||||
|
id="reportDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.reportDate}
|
||||||
|
onChange={handleInputChange('reportDate')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이슈해결일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="resolvedDate">이슈해결일</Label>
|
||||||
|
<Input
|
||||||
|
id="resolvedDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.resolvedDate}
|
||||||
|
onChange={handleInputChange('resolvedDate')}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="status">상태</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(value) => handleSelectChange('status')(value as IssueStatus)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="status" className="w-full md:w-[200px]">
|
||||||
|
<SelectValue placeholder="상태 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_STATUS_FORM_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 이슈 보고 카드 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>이슈 보고</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 구분 & 중요도 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 구분 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">구분</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.category}
|
||||||
|
onValueChange={(value) => handleSelectChange('category')(value as IssueCategory)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="category">
|
||||||
|
<SelectValue placeholder="구분 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_CATEGORY_FORM_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중요도 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="priority">중요도</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.priority}
|
||||||
|
onValueChange={handlePriorityChange}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="priority">
|
||||||
|
<SelectValue placeholder="중요도 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_PRIORITY_FORM_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange('title')}
|
||||||
|
placeholder="제목을 입력하세요"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="content">내용</Label>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRecordClick}
|
||||||
|
>
|
||||||
|
<Mic className="mr-2 h-4 w-4" />
|
||||||
|
녹음
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={formData.content}
|
||||||
|
onChange={handleInputChange('content')}
|
||||||
|
placeholder="내용을 입력하세요"
|
||||||
|
rows={6}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사진 카드 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">사진</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-4">
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<div>
|
||||||
|
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span className="text-sm">사진 업로드</span>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드된 사진 목록 */}
|
||||||
|
{formData.images.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{formData.images.map((image) => (
|
||||||
|
<div key={image.id} className="relative group">
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.fileName}
|
||||||
|
className="w-full h-32 object-cover rounded-lg border"
|
||||||
|
/>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleImageRemove(image.id)}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground truncate mt-1">
|
||||||
|
{image.fileName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-4">
|
||||||
|
업로드된 사진이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 철회 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>이슈 철회</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 이슈를 철회하시겠습니까?
|
||||||
|
<br />
|
||||||
|
철회된 이슈는 복구할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleWithdraw}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
철회
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2 } 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 { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type {
|
||||||
|
Issue,
|
||||||
|
IssueStats,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
ISSUE_STATUS_OPTIONS,
|
||||||
|
ISSUE_PRIORITY_OPTIONS,
|
||||||
|
ISSUE_CATEGORY_OPTIONS,
|
||||||
|
ISSUE_SORT_OPTIONS,
|
||||||
|
ISSUE_STATUS_STYLES,
|
||||||
|
ISSUE_STATUS_LABELS,
|
||||||
|
ISSUE_PRIORITY_STYLES,
|
||||||
|
ISSUE_PRIORITY_LABELS,
|
||||||
|
ISSUE_CATEGORY_LABELS,
|
||||||
|
MOCK_ISSUE_PARTNERS,
|
||||||
|
MOCK_ISSUE_SITES,
|
||||||
|
MOCK_ISSUE_REPORTERS,
|
||||||
|
MOCK_ISSUE_ASSIGNEES,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
getIssueList,
|
||||||
|
getIssueStats,
|
||||||
|
withdrawIssues,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
// 체크박스, 번호, 이슈번호, 시공번호, 거래처, 현장, 구분, 제목, 보고자, 이슈보고일, 이슈해결일, 담당자, 중요도, 상태, 작업
|
||||||
|
const tableColumns: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||||
|
{ key: 'issueNumber', label: '이슈번호', className: 'w-[120px]' },
|
||||||
|
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
|
||||||
|
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||||
|
{ key: 'siteName', label: '현장', className: 'min-w-[120px]' },
|
||||||
|
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'title', label: '제목', className: 'min-w-[150px]' },
|
||||||
|
{ key: 'reporter', label: '보고자', className: 'w-[80px]' },
|
||||||
|
{ key: 'reportDate', label: '이슈보고일', className: 'w-[100px]' },
|
||||||
|
{ key: 'resolvedDate', label: '이슈해결일', className: 'w-[100px]' },
|
||||||
|
{ key: 'assignee', label: '담당자', className: 'w-[80px]' },
|
||||||
|
{ key: 'priority', label: '중요도', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IssueManagementListClientProps {
|
||||||
|
initialData?: Issue[];
|
||||||
|
initialStats?: IssueStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IssueManagementListClient({
|
||||||
|
initialData = [],
|
||||||
|
initialStats,
|
||||||
|
}: IssueManagementListClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [issues, setIssues] = useState<Issue[]>(initialData);
|
||||||
|
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// 다중선택 필터 (빈 배열 = 전체)
|
||||||
|
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||||
|
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||||
|
const [categoryFilters, setCategoryFilters] = useState<string[]>([]);
|
||||||
|
const [reporterFilters, setReporterFilters] = useState<string[]>([]);
|
||||||
|
const [assigneeFilters, setAssigneeFilters] = useState<string[]>([]);
|
||||||
|
// 단일선택 필터
|
||||||
|
const [priorityFilter, setPriorityFilter] = 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 [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
|
||||||
|
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getIssueList({
|
||||||
|
size: 1000,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
}),
|
||||||
|
getIssueStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setIssues(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]);
|
||||||
|
|
||||||
|
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||||
|
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_ISSUE_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||||
|
[]);
|
||||||
|
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_ISSUE_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||||
|
[]);
|
||||||
|
const categoryOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
ISSUE_CATEGORY_OPTIONS.filter(c => c.value !== 'all').map(c => ({ value: c.value, label: c.label })),
|
||||||
|
[]);
|
||||||
|
const reporterOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_ISSUE_REPORTERS.map(r => ({ value: r.value, label: r.label })),
|
||||||
|
[]);
|
||||||
|
const assigneeOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_ISSUE_ASSIGNEES.map(a => ({ value: a.value, label: a.label })),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredIssues = useMemo(() => {
|
||||||
|
return issues.filter((item) => {
|
||||||
|
// 상태 탭 필터
|
||||||
|
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// 중요도 필터
|
||||||
|
if (priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (partnerFilters.length > 0) {
|
||||||
|
const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
|
||||||
|
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장 필터 (다중선택)
|
||||||
|
if (siteFilters.length > 0) {
|
||||||
|
const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
|
||||||
|
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구분 필터 (다중선택)
|
||||||
|
if (categoryFilters.length > 0) {
|
||||||
|
if (!categoryFilters.includes(item.category)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보고자 필터 (다중선택)
|
||||||
|
if (reporterFilters.length > 0) {
|
||||||
|
const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
|
||||||
|
if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 담당자 필터 (다중선택)
|
||||||
|
if (assigneeFilters.length > 0) {
|
||||||
|
const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
|
||||||
|
if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchValue) {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.issueNumber.toLowerCase().includes(search) ||
|
||||||
|
item.constructionNumber.toLowerCase().includes(search) ||
|
||||||
|
item.partnerName.toLowerCase().includes(search) ||
|
||||||
|
item.siteName.toLowerCase().includes(search) ||
|
||||||
|
item.title.toLowerCase().includes(search) ||
|
||||||
|
item.reporter.toLowerCase().includes(search) ||
|
||||||
|
item.assignee.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [issues, activeStatTab, statusFilter, priorityFilter, partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, searchValue]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedIssues = useMemo(() => {
|
||||||
|
const sorted = [...filteredIssues];
|
||||||
|
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 'reportDate':
|
||||||
|
sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||||
|
break;
|
||||||
|
case 'priorityHigh':
|
||||||
|
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
|
||||||
|
sorted.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||||
|
break;
|
||||||
|
case 'priorityLow':
|
||||||
|
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||||
|
sorted.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [filteredIssues, sortBy]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(sortedIssues.length / itemsPerPage);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedIssues.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedIssues, 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((c) => c.id)));
|
||||||
|
}
|
||||||
|
}, [selectedItems.size, paginatedData]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(item: Issue) => {
|
||||||
|
router.push(`/ko/construction/project/issue-management/${item.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(e: React.MouseEvent, itemId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/ko/construction/project/issue-management/${itemId}/edit`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateIssue = useCallback(() => {
|
||||||
|
router.push('/ko/construction/project/issue-management/new');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 철회 다이얼로그 열기
|
||||||
|
const handleWithdrawClick = useCallback(() => {
|
||||||
|
if (selectedItems.size === 0) {
|
||||||
|
toast.error('철회할 이슈를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWithdrawDialogOpen(true);
|
||||||
|
}, [selectedItems.size]);
|
||||||
|
|
||||||
|
// 철회 실행
|
||||||
|
const handleWithdraw = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const ids = Array.from(selectedItems);
|
||||||
|
const result = await withdrawIssues(ids);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '이슈 철회에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('이슈 철회에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setWithdrawDialogOpen(false);
|
||||||
|
}
|
||||||
|
}, [selectedItems, loadData]);
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return dateStr.split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
const renderTableRow = useCallback(
|
||||||
|
(item: Issue, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(item.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{item.issueNumber}</TableCell>
|
||||||
|
<TableCell>{item.constructionNumber}</TableCell>
|
||||||
|
<TableCell>{item.partnerName}</TableCell>
|
||||||
|
<TableCell>{item.siteName}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||||
|
{ISSUE_CATEGORY_LABELS[item.category]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate" title={item.title}>{item.title}</TableCell>
|
||||||
|
<TableCell>{item.reporter}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.reportDate)}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.resolvedDate)}</TableCell>
|
||||||
|
<TableCell>{item.assignee}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_PRIORITY_STYLES[item.priority]}`}>
|
||||||
|
{ISSUE_PRIORITY_LABELS[item.priority]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_STATUS_STYLES[item.status]}`}>
|
||||||
|
{ISSUE_STATUS_LABELS[item.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, item.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
const renderMobileCard = useCallback(
|
||||||
|
(item: Issue, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
title={item.title}
|
||||||
|
subtitle={item.issueNumber}
|
||||||
|
badge={ISSUE_STATUS_LABELS[item.status]}
|
||||||
|
badgeVariant="secondary"
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
details={[
|
||||||
|
{ label: '거래처', value: item.partnerName },
|
||||||
|
{ label: '현장', value: item.siteName },
|
||||||
|
{ label: '보고일', value: formatDate(item.reportDate) },
|
||||||
|
{ label: '중요도', value: ISSUE_PRIORITY_LABELS[item.priority] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 헤더 액션 (DateRangeSelector + 이슈 등록 버튼)
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
extraActions={
|
||||||
|
<Button onClick={handleCreateIssue}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
이슈 등록
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 통계 카드 클릭 핸들러
|
||||||
|
const handleStatClick = useCallback((tab: 'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved') => {
|
||||||
|
setActiveStatTab(tab);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 통계 카드 데이터
|
||||||
|
const statsCardsData: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: '접수',
|
||||||
|
value: stats?.received ?? 0,
|
||||||
|
icon: Inbox,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
onClick: () => handleStatClick('received'),
|
||||||
|
isActive: activeStatTab === 'received',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '처리중',
|
||||||
|
value: stats?.inProgress ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
onClick: () => handleStatClick('in_progress'),
|
||||||
|
isActive: activeStatTab === 'in_progress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '해결완료',
|
||||||
|
value: stats?.resolved ?? 0,
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
onClick: () => handleStatClick('resolved'),
|
||||||
|
isActive: activeStatTab === 'resolved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '미해결',
|
||||||
|
value: stats?.unresolved ?? 0,
|
||||||
|
icon: XCircle,
|
||||||
|
iconColor: 'text-red-600',
|
||||||
|
onClick: () => handleStatClick('unresolved'),
|
||||||
|
isActive: activeStatTab === 'unresolved',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 테이블 헤더 액션
|
||||||
|
// 철회 버튼 (선택 시), 거래처(다중), 현장명(다중), 구분(다중), 보고자(다중), 담당자(다중), 중요도(일반), 상태(일반), 정렬
|
||||||
|
const tableHeaderActions = (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end w-full">
|
||||||
|
{/* 철회 버튼 (선택된 항목이 있을 때만 표시) */}
|
||||||
|
{selectedItems.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleWithdrawClick}
|
||||||
|
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
<Undo2 className="mr-2 h-4 w-4" />
|
||||||
|
철회 ({selectedItems.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 1. 거래처 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={partnerOptions}
|
||||||
|
value={partnerFilters}
|
||||||
|
onChange={setPartnerFilters}
|
||||||
|
placeholder="거래처"
|
||||||
|
searchPlaceholder="거래처 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. 현장명 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={siteOptions}
|
||||||
|
value={siteFilters}
|
||||||
|
onChange={setSiteFilters}
|
||||||
|
placeholder="현장명"
|
||||||
|
searchPlaceholder="현장명 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. 구분 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={categoryOptions}
|
||||||
|
value={categoryFilters}
|
||||||
|
onChange={setCategoryFilters}
|
||||||
|
placeholder="구분"
|
||||||
|
searchPlaceholder="구분 검색..."
|
||||||
|
className="w-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. 보고자 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={reporterOptions}
|
||||||
|
value={reporterFilters}
|
||||||
|
onChange={setReporterFilters}
|
||||||
|
placeholder="보고자"
|
||||||
|
searchPlaceholder="보고자 검색..."
|
||||||
|
className="w-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 5. 담당자 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={assigneeOptions}
|
||||||
|
value={assigneeFilters}
|
||||||
|
onChange={setAssigneeFilters}
|
||||||
|
placeholder="담당자"
|
||||||
|
searchPlaceholder="담당자 검색..."
|
||||||
|
className="w-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 6. 중요도 필터 (단일선택) */}
|
||||||
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="중요도" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_PRIORITY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 7. 상태 필터 (단일선택) */}
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_STATUS_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 8. 정렬 (단일선택) */}
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="정렬" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUE_SORT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="이슈관리"
|
||||||
|
description="이슈 목록을 관리합니다"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
headerActions={headerActions}
|
||||||
|
stats={statsCardsData}
|
||||||
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색"
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
data={paginatedData}
|
||||||
|
allData={sortedIssues}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: sortedIssues.length,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 철회 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>이슈 철회</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
선택한 {selectedItems.size}건의 이슈를 철회하시겠습니까?
|
||||||
|
<br />
|
||||||
|
철회된 이슈는 복구할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleWithdraw}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
철회
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
src/components/business/construction/issue-management/actions.ts
Normal file
417
src/components/business/construction/issue-management/actions.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Issue,
|
||||||
|
IssueStats,
|
||||||
|
IssueFilter,
|
||||||
|
IssueListResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈관리 Server Actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock 이슈 데이터
|
||||||
|
const mockIssues: Issue[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
issueNumber: 'ISS-2025-001',
|
||||||
|
constructionNumber: 'CON-001',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '홍길동',
|
||||||
|
constructionManagers: '홍길동, 김철수, 이영희',
|
||||||
|
category: 'material',
|
||||||
|
title: '자재 품질 불량',
|
||||||
|
content: '납품된 철근 일부에 녹이 발생하여 품질 검수가 필요합니다.',
|
||||||
|
reporter: '홍길동',
|
||||||
|
reportDate: '2025-09-01',
|
||||||
|
resolvedDate: '2025-09-03',
|
||||||
|
assignee: '김과장',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'resolved',
|
||||||
|
description: '납품된 철근 일부에 녹이 발생',
|
||||||
|
createdAt: '2025-09-01T09:00:00Z',
|
||||||
|
updatedAt: '2025-09-03T15:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
issueNumber: 'ISS-2025-002',
|
||||||
|
constructionNumber: 'CON-002',
|
||||||
|
partnerName: '삼성시공',
|
||||||
|
siteName: '부산 해운대 현장',
|
||||||
|
constructionPM: '김철수',
|
||||||
|
constructionManagers: '김철수, 박민수',
|
||||||
|
category: 'safety',
|
||||||
|
title: '안전장비 미착용',
|
||||||
|
content: '현장 작업자 안전모 미착용 발견되어 시정 조치가 필요합니다.',
|
||||||
|
reporter: '김철수',
|
||||||
|
reportDate: '2025-09-02',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '이부장',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'in_progress',
|
||||||
|
description: '현장 작업자 안전모 미착용 발견',
|
||||||
|
createdAt: '2025-09-02T10:00:00Z',
|
||||||
|
updatedAt: '2025-09-02T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
issueNumber: 'ISS-2025-003',
|
||||||
|
constructionNumber: 'CON-001',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '홍길동',
|
||||||
|
constructionManagers: '홍길동, 김철수, 이영희',
|
||||||
|
category: 'process',
|
||||||
|
title: '공정 지연',
|
||||||
|
content: '우천으로 인한 외부 공사가 지연되고 있습니다.',
|
||||||
|
reporter: '이영희',
|
||||||
|
reportDate: '2025-09-03',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '박대리',
|
||||||
|
priority: 'normal',
|
||||||
|
status: 'received',
|
||||||
|
description: '우천으로 인한 외부 공사 지연',
|
||||||
|
createdAt: '2025-09-03T08:00:00Z',
|
||||||
|
updatedAt: '2025-09-03T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
issueNumber: 'ISS-2025-004',
|
||||||
|
constructionNumber: 'CON-003',
|
||||||
|
partnerName: 'LG건설',
|
||||||
|
siteName: '대전 유성 현장',
|
||||||
|
constructionPM: '이영희',
|
||||||
|
constructionManagers: '이영희, 최대리',
|
||||||
|
category: 'etc',
|
||||||
|
title: '예산 초과 우려',
|
||||||
|
content: '자재비 상승으로 인한 예산 초과가 예상됩니다.',
|
||||||
|
reporter: '박민수',
|
||||||
|
reportDate: '2025-09-01',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '정차장',
|
||||||
|
priority: 'normal',
|
||||||
|
status: 'unresolved',
|
||||||
|
description: '자재비 상승으로 인한 예산 초과 예상',
|
||||||
|
createdAt: '2025-09-01T11:00:00Z',
|
||||||
|
updatedAt: '2025-09-01T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
issueNumber: 'ISS-2025-005',
|
||||||
|
constructionNumber: 'CON-004',
|
||||||
|
partnerName: '현대건설',
|
||||||
|
siteName: '인천 송도 현장',
|
||||||
|
constructionPM: '박민수',
|
||||||
|
constructionManagers: '박민수, 홍길동',
|
||||||
|
category: 'etc',
|
||||||
|
title: '민원 발생',
|
||||||
|
content: '인근 주민으로부터 소음 민원이 접수되었습니다.',
|
||||||
|
reporter: '최대리',
|
||||||
|
reportDate: '2025-09-02',
|
||||||
|
resolvedDate: '2025-09-02',
|
||||||
|
assignee: '송이사',
|
||||||
|
priority: 'normal',
|
||||||
|
status: 'resolved',
|
||||||
|
description: '소음 민원 접수',
|
||||||
|
createdAt: '2025-09-02T14:00:00Z',
|
||||||
|
updatedAt: '2025-09-02T18:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
issueNumber: 'ISS-2025-006',
|
||||||
|
constructionNumber: 'CON-002',
|
||||||
|
partnerName: '삼성시공',
|
||||||
|
siteName: '부산 해운대 현장',
|
||||||
|
constructionPM: '김철수',
|
||||||
|
constructionManagers: '김철수, 박민수',
|
||||||
|
category: 'material',
|
||||||
|
title: '시공 품질 미달',
|
||||||
|
content: '콘크리트 타설 품질이 기준에 미달합니다.',
|
||||||
|
reporter: '홍길동',
|
||||||
|
reportDate: '2025-09-03',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '김과장',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'received',
|
||||||
|
description: '콘크리트 타설 품질 기준 미달',
|
||||||
|
createdAt: '2025-09-03T09:30:00Z',
|
||||||
|
updatedAt: '2025-09-03T09:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
issueNumber: 'ISS-2025-007',
|
||||||
|
constructionNumber: 'CON-005',
|
||||||
|
partnerName: 'SK건설',
|
||||||
|
siteName: '광주 북구 현장',
|
||||||
|
constructionPM: '최대리',
|
||||||
|
constructionManagers: '최대리, 김철수, 이영희',
|
||||||
|
category: 'safety',
|
||||||
|
title: '장비 점검 필요',
|
||||||
|
content: '크레인 정기 점검 시기가 도래하여 점검이 필요합니다.',
|
||||||
|
reporter: '김철수',
|
||||||
|
reportDate: '2025-09-01',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '이부장',
|
||||||
|
priority: 'normal',
|
||||||
|
status: 'in_progress',
|
||||||
|
description: '크레인 정기 점검 시기 도래',
|
||||||
|
createdAt: '2025-09-01T13:00:00Z',
|
||||||
|
updatedAt: '2025-09-02T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
issueNumber: 'ISS-2025-008',
|
||||||
|
constructionNumber: 'CON-001',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '홍길동',
|
||||||
|
constructionManagers: '홍길동, 김철수, 이영희',
|
||||||
|
category: 'process',
|
||||||
|
title: '인력 부족',
|
||||||
|
content: '숙련공 부족으로 공사 진행에 어려움이 있습니다.',
|
||||||
|
reporter: '이영희',
|
||||||
|
reportDate: '2025-09-02',
|
||||||
|
resolvedDate: null,
|
||||||
|
assignee: '박대리',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'in_progress',
|
||||||
|
description: '숙련공 부족으로 공사 진행 어려움',
|
||||||
|
createdAt: '2025-09-02T08:30:00Z',
|
||||||
|
updatedAt: '2025-09-03T09:00:00Z',
|
||||||
|
},
|
||||||
|
// 추가 더미 데이터
|
||||||
|
...Array.from({ length: 47 }, (_, i) => ({
|
||||||
|
id: `${i + 9}`,
|
||||||
|
issueNumber: `ISS-2025-${String(i + 9).padStart(3, '0')}`,
|
||||||
|
constructionNumber: `CON-${String((i % 5) + 1).padStart(3, '0')}`,
|
||||||
|
partnerName: ['대한건설', '삼성시공', 'LG건설', '현대건설', 'SK건설'][i % 5],
|
||||||
|
siteName: ['서울 강남 현장', '부산 해운대 현장', '대전 유성 현장', '인천 송도 현장', '광주 북구 현장'][i % 5],
|
||||||
|
constructionPM: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
|
||||||
|
constructionManagers: ['홍길동, 김철수', '김철수, 박민수', '이영희, 최대리', '박민수, 홍길동', '최대리, 김철수'][i % 5],
|
||||||
|
category: (['material', 'drawing', 'process', 'safety', 'etc'] as const)[i % 5],
|
||||||
|
title: `이슈 ${i + 9}`,
|
||||||
|
content: `이슈 ${i + 9}에 대한 상세 내용입니다.`,
|
||||||
|
reporter: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
|
||||||
|
reportDate: `2025-09-${String((i % 28) + 1).padStart(2, '0')}`,
|
||||||
|
resolvedDate: i % 3 === 0 ? `2025-09-${String(Math.min((i % 28) + 3, 30)).padStart(2, '0')}` : null,
|
||||||
|
assignee: ['김과장', '이부장', '박대리', '정차장', '송이사'][i % 5],
|
||||||
|
priority: (['urgent', 'normal'] as const)[i % 2],
|
||||||
|
status: (['received', 'in_progress', 'resolved', 'unresolved'] as const)[i % 4],
|
||||||
|
description: `이슈 설명 ${i + 9}`,
|
||||||
|
createdAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
|
||||||
|
updatedAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 이슈 목록 조회
|
||||||
|
export async function getIssueList(
|
||||||
|
filter?: IssueFilter
|
||||||
|
): Promise<{ success: boolean; data?: IssueListResponse; error?: string }> {
|
||||||
|
try {
|
||||||
|
let filtered = [...mockIssues];
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (filter?.partners && filter.partners.length > 0) {
|
||||||
|
filtered = filtered.filter((issue) =>
|
||||||
|
filter.partners!.some((p) => issue.partnerName.includes(p) || p.includes(issue.partnerName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장 필터 (다중선택)
|
||||||
|
if (filter?.sites && filter.sites.length > 0) {
|
||||||
|
filtered = filtered.filter((issue) =>
|
||||||
|
filter.sites!.some((s) => issue.siteName.includes(s) || s.includes(issue.siteName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구분 필터 (다중선택)
|
||||||
|
if (filter?.categories && filter.categories.length > 0) {
|
||||||
|
filtered = filtered.filter((issue) =>
|
||||||
|
filter.categories!.includes(issue.category)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보고자 필터 (다중선택)
|
||||||
|
if (filter?.reporters && filter.reporters.length > 0) {
|
||||||
|
filtered = filtered.filter((issue) =>
|
||||||
|
filter.reporters!.some((r) => issue.reporter.includes(r) || r.includes(issue.reporter))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 담당자 필터 (다중선택)
|
||||||
|
if (filter?.assignees && filter.assignees.length > 0) {
|
||||||
|
filtered = filtered.filter((issue) =>
|
||||||
|
filter.assignees!.some((a) => issue.assignee.includes(a) || a.includes(issue.assignee))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중요도 필터 (단일선택)
|
||||||
|
if (filter?.priority && filter.priority !== 'all') {
|
||||||
|
filtered = filtered.filter((issue) => issue.priority === filter.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터 (단일선택)
|
||||||
|
if (filter?.status && filter.status !== 'all') {
|
||||||
|
filtered = filtered.filter((issue) => issue.status === filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (filter?.startDate) {
|
||||||
|
filtered = filtered.filter((issue) => issue.reportDate >= filter.startDate!);
|
||||||
|
}
|
||||||
|
if (filter?.endDate) {
|
||||||
|
filtered = filtered.filter((issue) => issue.reportDate <= 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 'reportDate':
|
||||||
|
filtered.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||||
|
break;
|
||||||
|
case 'priorityHigh':
|
||||||
|
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
|
||||||
|
filtered.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||||
|
break;
|
||||||
|
case 'priorityLow':
|
||||||
|
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||||
|
filtered.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
|
||||||
|
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('getIssueList error:', error);
|
||||||
|
return { success: false, error: '이슈 목록 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 통계 조회
|
||||||
|
export async function getIssueStats(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: IssueStats;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const received = mockIssues.filter((i) => i.status === 'received').length;
|
||||||
|
const inProgress = mockIssues.filter((i) => i.status === 'in_progress').length;
|
||||||
|
const resolved = mockIssues.filter((i) => i.status === 'resolved').length;
|
||||||
|
const unresolved = mockIssues.filter((i) => i.status === 'unresolved').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
received,
|
||||||
|
inProgress,
|
||||||
|
resolved,
|
||||||
|
unresolved,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getIssueStats error:', error);
|
||||||
|
return { success: false, error: '이슈 통계 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 상세 조회
|
||||||
|
export async function getIssue(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; data?: Issue; error?: string }> {
|
||||||
|
try {
|
||||||
|
const issue = mockIssues.find((i) => i.id === id);
|
||||||
|
|
||||||
|
if (!issue) {
|
||||||
|
return { success: false, error: '이슈를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: issue };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getIssue error:', error);
|
||||||
|
return { success: false, error: '이슈 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 수정
|
||||||
|
export async function updateIssue(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Issue>
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Update issue:', id, data);
|
||||||
|
// 실제 구현에서는 DB 업데이트
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateIssue error:', error);
|
||||||
|
return { success: false, error: '이슈 수정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 생성
|
||||||
|
export async function createIssue(
|
||||||
|
data: Omit<Issue, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
): Promise<{ success: boolean; data?: Issue; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Create issue:', data);
|
||||||
|
const newIssue: Issue = {
|
||||||
|
...data,
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return { success: true, data: newIssue };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('createIssue error:', error);
|
||||||
|
return { success: false, error: '이슈 등록에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 철회 (단일)
|
||||||
|
export async function withdrawIssue(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Withdraw issue:', id);
|
||||||
|
// 실제 구현에서는 DB 상태 업데이트 (삭제가 아닌 철회 상태로 변경)
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('withdrawIssue error:', error);
|
||||||
|
return { success: false, error: '이슈 철회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 철회 (다중)
|
||||||
|
export async function withdrawIssues(
|
||||||
|
ids: string[]
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Withdraw issues:', ids);
|
||||||
|
// 실제 구현에서는 DB 상태 일괄 업데이트
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('withdrawIssues error:', error);
|
||||||
|
return { success: false, error: '이슈 일괄 철회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as IssueManagementListClient } from './IssueManagementListClient';
|
||||||
|
export { default as IssueDetailForm } from './IssueDetailForm';
|
||||||
|
export * from './types';
|
||||||
|
export * from './actions';
|
||||||
237
src/components/business/construction/issue-management/types.ts
Normal file
237
src/components/business/construction/issue-management/types.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 이슈관리 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 이슈 상태
|
||||||
|
export type IssueStatus = 'received' | 'in_progress' | 'resolved' | 'unresolved';
|
||||||
|
|
||||||
|
// 이슈 중요도 (긴급, 일반)
|
||||||
|
export type IssuePriority = 'urgent' | 'normal';
|
||||||
|
|
||||||
|
// 이슈 구분 (자재, 도면, 공정, 안전, 기타)
|
||||||
|
export type IssueCategory = 'material' | 'drawing' | 'process' | 'safety' | 'etc';
|
||||||
|
|
||||||
|
// 이슈 이미지
|
||||||
|
export interface IssueImage {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 데이터
|
||||||
|
export interface Issue {
|
||||||
|
id: string;
|
||||||
|
issueNumber: string; // 이슈번호
|
||||||
|
constructionNumber: string; // 시공번호
|
||||||
|
partnerName: string; // 거래처
|
||||||
|
siteName: string; // 현장
|
||||||
|
constructionPM?: string; // 공사PM (자동)
|
||||||
|
constructionManagers?: string; // 공사담당자 (자동, 다중)
|
||||||
|
category: IssueCategory; // 구분
|
||||||
|
title: string; // 제목
|
||||||
|
content?: string; // 내용
|
||||||
|
reporter: string; // 보고자
|
||||||
|
reportDate: string; // 이슈보고일
|
||||||
|
resolvedDate: string | null; // 이슈해결일
|
||||||
|
assignee: string; // 담당자
|
||||||
|
priority: IssuePriority; // 중요도
|
||||||
|
status: IssueStatus; // 상태
|
||||||
|
images?: IssueImage[]; // 사진
|
||||||
|
description?: string; // 설명 (레거시)
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 폼 데이터
|
||||||
|
export interface IssueFormData {
|
||||||
|
issueNumber: string;
|
||||||
|
constructionNumber: string;
|
||||||
|
partnerName: string;
|
||||||
|
siteName: string;
|
||||||
|
constructionPM: string;
|
||||||
|
constructionManagers: string;
|
||||||
|
reporter: string;
|
||||||
|
assignee: string;
|
||||||
|
reportDate: string;
|
||||||
|
resolvedDate: string;
|
||||||
|
status: IssueStatus;
|
||||||
|
category: IssueCategory;
|
||||||
|
priority: IssuePriority;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
images: IssueImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 통계
|
||||||
|
export interface IssueStats {
|
||||||
|
received: number; // 접수
|
||||||
|
inProgress: number; // 처리중
|
||||||
|
resolved: number; // 해결완료
|
||||||
|
unresolved: number; // 미해결
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 필터
|
||||||
|
export interface IssueFilter {
|
||||||
|
partners?: string[]; // 거래처 (다중선택)
|
||||||
|
sites?: string[]; // 현장 (다중선택)
|
||||||
|
categories?: string[]; // 구분 (다중선택)
|
||||||
|
reporters?: string[]; // 보고자 (다중선택)
|
||||||
|
assignees?: string[]; // 담당자 (다중선택)
|
||||||
|
priority?: string; // 중요도 (단일선택)
|
||||||
|
status?: string; // 상태 (단일선택)
|
||||||
|
sortBy?: string; // 정렬
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답
|
||||||
|
export interface IssueListResponse {
|
||||||
|
items: Issue[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 옵션
|
||||||
|
export const ISSUE_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'received', label: '접수' },
|
||||||
|
{ value: 'in_progress', label: '처리중' },
|
||||||
|
{ value: 'resolved', label: '해결완료' },
|
||||||
|
{ value: 'unresolved', label: '미해결' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 상태 라벨
|
||||||
|
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
|
||||||
|
received: '접수',
|
||||||
|
in_progress: '처리중',
|
||||||
|
resolved: '해결완료',
|
||||||
|
unresolved: '미해결',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 스타일
|
||||||
|
export const ISSUE_STATUS_STYLES: Record<IssueStatus, string> = {
|
||||||
|
received: 'bg-blue-100 text-blue-700',
|
||||||
|
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||||
|
resolved: 'bg-green-100 text-green-700',
|
||||||
|
unresolved: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중요도 옵션 (긴급, 일반)
|
||||||
|
export const ISSUE_PRIORITY_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'urgent', label: '긴급' },
|
||||||
|
{ value: 'normal', label: '일반' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 중요도 라벨
|
||||||
|
export const ISSUE_PRIORITY_LABELS: Record<IssuePriority, string> = {
|
||||||
|
urgent: '긴급',
|
||||||
|
normal: '일반',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중요도 스타일
|
||||||
|
export const ISSUE_PRIORITY_STYLES: Record<IssuePriority, string> = {
|
||||||
|
urgent: 'bg-red-100 text-red-700',
|
||||||
|
normal: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 구분 옵션 (자재, 도면, 공정, 안전, 기타)
|
||||||
|
export const ISSUE_CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'material', label: '자재' },
|
||||||
|
{ value: 'drawing', label: '도면' },
|
||||||
|
{ value: 'process', label: '공정' },
|
||||||
|
{ value: 'safety', label: '안전' },
|
||||||
|
{ value: 'etc', label: '기타' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 구분 라벨
|
||||||
|
export const ISSUE_CATEGORY_LABELS: Record<IssueCategory, string> = {
|
||||||
|
material: '자재',
|
||||||
|
drawing: '도면',
|
||||||
|
process: '공정',
|
||||||
|
safety: '안전',
|
||||||
|
etc: '기타',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬 옵션
|
||||||
|
export const ISSUE_SORT_OPTIONS = [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '오래된순' },
|
||||||
|
{ value: 'reportDate', label: '보고일순' },
|
||||||
|
{ value: 'priorityHigh', label: '중요도 높은순' },
|
||||||
|
{ value: 'priorityLow', label: '중요도 낮은순' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 거래처 데이터
|
||||||
|
export const MOCK_ISSUE_PARTNERS = [
|
||||||
|
{ value: 'partner1', label: '대한건설' },
|
||||||
|
{ value: 'partner2', label: '삼성시공' },
|
||||||
|
{ value: 'partner3', label: 'LG건설' },
|
||||||
|
{ value: 'partner4', label: '현대건설' },
|
||||||
|
{ value: 'partner5', label: 'SK건설' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 현장 데이터
|
||||||
|
export const MOCK_ISSUE_SITES = [
|
||||||
|
{ value: 'site1', label: '서울 강남 현장' },
|
||||||
|
{ value: 'site2', label: '부산 해운대 현장' },
|
||||||
|
{ value: 'site3', label: '대전 유성 현장' },
|
||||||
|
{ value: 'site4', label: '인천 송도 현장' },
|
||||||
|
{ value: 'site5', label: '광주 북구 현장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 보고자 데이터
|
||||||
|
export const MOCK_ISSUE_REPORTERS = [
|
||||||
|
{ value: 'reporter1', label: '홍길동' },
|
||||||
|
{ value: 'reporter2', label: '김철수' },
|
||||||
|
{ value: 'reporter3', label: '이영희' },
|
||||||
|
{ value: 'reporter4', label: '박민수' },
|
||||||
|
{ value: 'reporter5', label: '최대리' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 담당자 데이터
|
||||||
|
export const MOCK_ISSUE_ASSIGNEES = [
|
||||||
|
{ value: 'assignee1', label: '김과장' },
|
||||||
|
{ value: 'assignee2', label: '이부장' },
|
||||||
|
{ value: 'assignee3', label: '박대리' },
|
||||||
|
{ value: 'assignee4', label: '정차장' },
|
||||||
|
{ value: 'assignee5', label: '송이사' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 시공번호 데이터 (상세 폼용)
|
||||||
|
export const MOCK_CONSTRUCTION_NUMBERS = [
|
||||||
|
{ value: 'CON-001', label: 'CON-001', partnerName: '대한건설', siteName: '서울 강남 현장', pm: '홍길동', managers: '홍길동, 김철수, 이영희' },
|
||||||
|
{ value: 'CON-002', label: 'CON-002', partnerName: '삼성시공', siteName: '부산 해운대 현장', pm: '김철수', managers: '김철수, 박민수' },
|
||||||
|
{ value: 'CON-003', label: 'CON-003', partnerName: 'LG건설', siteName: '대전 유성 현장', pm: '이영희', managers: '이영희, 최대리' },
|
||||||
|
{ value: 'CON-004', label: 'CON-004', partnerName: '현대건설', siteName: '인천 송도 현장', pm: '박민수', managers: '박민수, 홍길동' },
|
||||||
|
{ value: 'CON-005', label: 'CON-005', partnerName: 'SK건설', siteName: '광주 북구 현장', pm: '최대리', managers: '최대리, 김철수, 이영희' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 폼용 상태 옵션 (전체 제외)
|
||||||
|
export const ISSUE_STATUS_FORM_OPTIONS = [
|
||||||
|
{ value: 'received', label: '접수' },
|
||||||
|
{ value: 'in_progress', label: '처리중' },
|
||||||
|
{ value: 'resolved', label: '해결완료' },
|
||||||
|
{ value: 'unresolved', label: '미해결' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 폼용 중요도 옵션 (전체 제외)
|
||||||
|
export const ISSUE_PRIORITY_FORM_OPTIONS = [
|
||||||
|
{ value: 'urgent', label: '긴급' },
|
||||||
|
{ value: 'normal', label: '일반' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 폼용 구분 옵션 (전체 제외)
|
||||||
|
export const ISSUE_CATEGORY_FORM_OPTIONS = [
|
||||||
|
{ value: 'material', label: '자재' },
|
||||||
|
{ value: 'drawing', label: '도면' },
|
||||||
|
{ value: 'process', label: '공정' },
|
||||||
|
{ value: 'safety', label: '안전' },
|
||||||
|
{ value: 'etc', label: '기타' },
|
||||||
|
];
|
||||||
@@ -512,59 +512,58 @@ export default function ItemDetailClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 발주 항목 */}
|
{/* 발주 항목 구분정보 */}
|
||||||
<Card>
|
{/* 수정 모드: 항상 표시 (추가/삭제 가능) */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
{/* 상세 모드: 데이터가 있을 때만 표시 (읽기 전용) */}
|
||||||
<CardTitle className="text-base">발주 항목</CardTitle>
|
{(!isReadOnly || formData.orderItems.length > 0) && (
|
||||||
{!isReadOnly && (
|
<div className="pt-4">
|
||||||
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
|
{/* 헤더 */}
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<div className="grid grid-cols-[1fr_1fr_auto] gap-4 items-center mb-4">
|
||||||
추가
|
<div className="text-base font-semibold">발주 항목</div>
|
||||||
</Button>
|
<div className="text-base font-semibold">구분 정보</div>
|
||||||
)}
|
{!isReadOnly && (
|
||||||
</CardHeader>
|
<Button size="sm" onClick={handleAddOrderItem}>
|
||||||
<CardContent>
|
추가
|
||||||
{formData.orderItems.length === 0 ? (
|
</Button>
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
)}
|
||||||
발주 항목이 없습니다.
|
|
||||||
{!isReadOnly && ' 추가 버튼을 클릭하여 항목을 추가하세요.'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-[1fr_1fr_40px] gap-2 text-sm font-medium text-muted-foreground">
|
|
||||||
<div>항목명</div>
|
|
||||||
<div>구분 정보</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
</div>
|
||||||
{formData.orderItems.map((item) => (
|
|
||||||
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-center">
|
{/* 항목 리스트 */}
|
||||||
<Input
|
{formData.orderItems.length === 0 ? (
|
||||||
value={item.label}
|
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
||||||
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
발주 항목이 없습니다. 추가 버튼을 클릭하여 항목을 추가하세요.
|
||||||
placeholder="예: 무게"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={item.value}
|
|
||||||
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
|
||||||
placeholder="예: 400KG"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
|
||||||
{!isReadOnly && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9"
|
|
||||||
onClick={() => handleRemoveOrderItem(item.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.orderItems.map((item) => (
|
||||||
|
<div key={item.id} className={`grid ${isReadOnly ? 'grid-cols-2' : 'grid-cols-[1fr_1fr_auto]'} gap-4 items-center`}>
|
||||||
|
<Input
|
||||||
|
value={item.label}
|
||||||
|
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
||||||
|
placeholder="예: 무게"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
||||||
|
placeholder="예: 400KG"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 bg-black hover:bg-black/80"
|
||||||
|
onClick={() => handleRemoveOrderItem(item.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-white" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export default function LaborDetailClient({
|
|||||||
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
|
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
|
||||||
const [originalData, setOriginalData] = useState<Labor | null>(null);
|
const [originalData, setOriginalData] = useState<Labor | null>(null);
|
||||||
|
|
||||||
|
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
|
||||||
|
const [minMInput, setMinMInput] = useState<string>('');
|
||||||
|
const [maxMInput, setMaxMInput] = useState<string>('');
|
||||||
|
|
||||||
// 상태
|
// 상태
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -84,6 +88,9 @@ export default function LaborDetailClient({
|
|||||||
laborPrice: result.data.laborPrice,
|
laborPrice: result.data.laborPrice,
|
||||||
status: result.data.status,
|
status: result.data.status,
|
||||||
});
|
});
|
||||||
|
// 소수점 입력용 문자열 상태 초기화
|
||||||
|
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
|
||||||
|
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||||
router.push('/ko/construction/order/base-info/labor');
|
router.push('/ko/construction/order/base-info/labor');
|
||||||
@@ -107,19 +114,62 @@ export default function LaborDetailClient({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 숫자 입력 (소수점 둘째자리까지)
|
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
|
||||||
const handleNumberChange = useCallback(
|
const handleMinMChange = useCallback(
|
||||||
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
|
(value: string) => {
|
||||||
|
// 빈 값 허용
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
handleFieldChange(field, field === 'laborPrice' ? null : 0);
|
setMinMInput('');
|
||||||
|
handleFieldChange('minM', 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 소수점 둘째자리까지 허용
|
// 소수점 둘째자리까지 허용하는 정규식
|
||||||
const regex = /^\d*\.?\d{0,2}$/;
|
const regex = /^\d*\.?\d{0,2}$/;
|
||||||
if (regex.test(value)) {
|
if (regex.test(value)) {
|
||||||
|
setMinMInput(value);
|
||||||
const numValue = parseFloat(value);
|
const numValue = parseFloat(value);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
handleFieldChange(field, numValue);
|
handleFieldChange('minM', numValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFieldChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMaxMChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
// 빈 값 허용
|
||||||
|
if (value === '') {
|
||||||
|
setMaxMInput('');
|
||||||
|
handleFieldChange('maxM', 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 소수점 둘째자리까지 허용하는 정규식
|
||||||
|
const regex = /^\d*\.?\d{0,2}$/;
|
||||||
|
if (regex.test(value)) {
|
||||||
|
setMaxMInput(value);
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
handleFieldChange('maxM', numValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFieldChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 노임단가 입력 핸들러 (정수만)
|
||||||
|
const handleLaborPriceChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === '') {
|
||||||
|
handleFieldChange('laborPrice', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 정수만 허용
|
||||||
|
const regex = /^\d*$/;
|
||||||
|
if (regex.test(value)) {
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
handleFieldChange('laborPrice', numValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -213,6 +263,9 @@ export default function LaborDetailClient({
|
|||||||
laborPrice: originalData.laborPrice,
|
laborPrice: originalData.laborPrice,
|
||||||
status: originalData.status,
|
status: originalData.status,
|
||||||
});
|
});
|
||||||
|
// 소수점 입력용 문자열 상태도 복원
|
||||||
|
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
|
||||||
|
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
|
||||||
}
|
}
|
||||||
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
|
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
|
||||||
}
|
}
|
||||||
@@ -339,8 +392,8 @@ export default function LaborDetailClient({
|
|||||||
id="minM"
|
id="minM"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formData.minM === 0 ? '' : formData.minM.toString()}
|
value={minMInput}
|
||||||
onChange={(e) => handleNumberChange('minM', e.target.value)}
|
onChange={(e) => handleMinMChange(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
@@ -351,8 +404,8 @@ export default function LaborDetailClient({
|
|||||||
id="maxM"
|
id="maxM"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
|
value={maxMInput}
|
||||||
onChange={(e) => handleNumberChange('maxM', e.target.value)}
|
onChange={(e) => handleMaxMChange(e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
@@ -366,9 +419,9 @@ export default function LaborDetailClient({
|
|||||||
<Input
|
<Input
|
||||||
id="laborPrice"
|
id="laborPrice"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="numeric"
|
||||||
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
|
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
|
||||||
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
|
onChange={(e) => handleLaborPriceChange(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// 구분 옵션
|
// 구분 옵션
|
||||||
export const CATEGORY_OPTIONS = [
|
export const CATEGORY_OPTIONS = [
|
||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '작업반장', label: '작업반장' },
|
||||||
|
{ value: '작업자', label: '작업자' },
|
||||||
{ value: '가로', label: '가로' },
|
{ value: '가로', label: '가로' },
|
||||||
{ value: '세로할증', label: '세로할증' },
|
{ value: '세로할증', label: '세로할증' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 노임관리 타입 정의
|
// 노임관리 타입 정의
|
||||||
|
|
||||||
// 구분 타입
|
// 구분 타입
|
||||||
export type LaborCategory = '가로' | '세로할증';
|
export type LaborCategory = '작업반장' | '작업자' | '가로' | '세로할증';
|
||||||
|
|
||||||
// 상태 타입
|
// 상태 타입
|
||||||
export type LaborStatus = '사용' | '중지';
|
export type LaborStatus = '사용' | '중지';
|
||||||
|
|||||||
@@ -0,0 +1,773 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Wrench, List, Plus, Trash2, FileText, Upload, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
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 { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
getConstructionManagementDetail,
|
||||||
|
updateConstructionManagementDetail,
|
||||||
|
completeConstruction,
|
||||||
|
} from './actions';
|
||||||
|
import { getOrderDetailFull } from '../order-management/actions';
|
||||||
|
import { OrderDocumentModal } from '../order-management/modals/OrderDocumentModal';
|
||||||
|
import type {
|
||||||
|
ConstructionManagementDetail,
|
||||||
|
ConstructionDetailFormData,
|
||||||
|
WorkerInfo,
|
||||||
|
WorkProgressInfo,
|
||||||
|
PhotoInfo,
|
||||||
|
} from './types';
|
||||||
|
import type { OrderDetail } from '../order-management/types';
|
||||||
|
import {
|
||||||
|
MOCK_EMPLOYEES,
|
||||||
|
MOCK_CM_WORK_TEAM_LEADERS,
|
||||||
|
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
|
||||||
|
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
interface ConstructionDetailClientProps {
|
||||||
|
id: string;
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 포맷팅
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstructionDetailClient({ id, mode }: ConstructionDetailClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 모드 플래그
|
||||||
|
const isViewMode = mode === 'view';
|
||||||
|
const isEditMode = mode === 'edit';
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [detail, setDetail] = useState<ConstructionManagementDetail | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 폼 데이터 상태
|
||||||
|
const [formData, setFormData] = useState<ConstructionDetailFormData>({
|
||||||
|
workTeamLeader: '',
|
||||||
|
workerInfoList: [],
|
||||||
|
workProgressList: [],
|
||||||
|
workLogContent: '',
|
||||||
|
photos: [],
|
||||||
|
isIssueReported: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발주서 모달 상태
|
||||||
|
const [showOrderModal, setShowOrderModal] = useState(false);
|
||||||
|
const [orderData, setOrderData] = useState<OrderDetail | null>(null);
|
||||||
|
|
||||||
|
// 시공 완료 다이얼로그 상태
|
||||||
|
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const result = await getConstructionManagementDetail(id);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDetail(result.data);
|
||||||
|
setFormData({
|
||||||
|
workTeamLeader: result.data.workTeamLeader,
|
||||||
|
workerInfoList: result.data.workerInfoList,
|
||||||
|
workProgressList: result.data.workProgressList,
|
||||||
|
workLogContent: result.data.workLogContent,
|
||||||
|
photos: result.data.photos,
|
||||||
|
isIssueReported: result.data.isIssueReported,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '시공 정보를 불러올 수 없습니다.');
|
||||||
|
router.push('/ko/construction/project/construction-management');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load construction detail:', error);
|
||||||
|
toast.error('시공 정보를 불러올 수 없습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
// 목록으로 돌아가기
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/ko/construction/project/construction-management');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수정 페이지로 이동
|
||||||
|
const handleEdit = () => {
|
||||||
|
router.push(`/ko/construction/project/construction-management/${id}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소 (상세 페이지로 돌아가기)
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push(`/ko/construction/project/construction-management/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업반장 변경
|
||||||
|
const handleWorkTeamLeaderChange = (value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, workTeamLeader: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업자 정보 추가
|
||||||
|
const handleAddWorkerInfo = () => {
|
||||||
|
const newWorkerInfo: WorkerInfo = {
|
||||||
|
id: `worker-${Date.now()}`,
|
||||||
|
workDate: new Date().toISOString().split('T')[0],
|
||||||
|
workers: [],
|
||||||
|
};
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workerInfoList: [...prev.workerInfoList, newWorkerInfo],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업자 정보 삭제
|
||||||
|
const handleDeleteWorkerInfo = (workerId: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workerInfoList: prev.workerInfoList.filter((w) => w.id !== workerId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업자 정보 변경
|
||||||
|
const handleWorkerInfoChange = (
|
||||||
|
workerId: string,
|
||||||
|
field: keyof WorkerInfo,
|
||||||
|
value: string | string[]
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workerInfoList: prev.workerInfoList.map((w) =>
|
||||||
|
w.id === workerId ? { ...w, [field]: value } : w
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공과 정보 추가
|
||||||
|
const handleAddWorkProgress = () => {
|
||||||
|
const newProgress: WorkProgressInfo = {
|
||||||
|
id: `progress-${Date.now()}`,
|
||||||
|
scheduleDate: '',
|
||||||
|
workName: '',
|
||||||
|
};
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workProgressList: [...prev.workProgressList, newProgress],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공과 정보 삭제
|
||||||
|
const handleDeleteWorkProgress = (progressId: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workProgressList: prev.workProgressList.filter((p) => p.id !== progressId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공과 정보 변경
|
||||||
|
const handleWorkProgressChange = (
|
||||||
|
progressId: string,
|
||||||
|
field: keyof WorkProgressInfo,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
workProgressList: prev.workProgressList.map((p) =>
|
||||||
|
p.id === progressId ? { ...p, [field]: value } : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업일지 변경
|
||||||
|
const handleWorkLogChange = (value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, workLogContent: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사진 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
|
||||||
|
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
// 임시 목업: 파일 정보를 photos에 추가
|
||||||
|
const newPhotos: PhotoInfo[] = Array.from(files).map((file, index) => ({
|
||||||
|
id: `photo-${Date.now()}-${index}`,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
name: file.name,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
photos: [...prev.photos, ...newPhotos],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사진 삭제
|
||||||
|
const handleDeletePhoto = (photoId: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
photos: prev.photos.filter((p) => p.id !== photoId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 발주서 보기
|
||||||
|
const handleViewOrder = async () => {
|
||||||
|
if (!detail?.orderId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrderDetailFull(detail.orderId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setOrderData(result.data);
|
||||||
|
setShowOrderModal(true);
|
||||||
|
} else {
|
||||||
|
toast.error('발주서 정보를 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load order detail:', error);
|
||||||
|
toast.error('발주서 정보를 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateConstructionManagementDetail(id, formData);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('저장되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save:', error);
|
||||||
|
toast.error('저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공 완료 버튼 활성화 조건: 작업일지 + 사진 모두 있어야 함
|
||||||
|
const canComplete =
|
||||||
|
detail?.status === 'in_progress' &&
|
||||||
|
formData.workLogContent.trim() !== '' &&
|
||||||
|
formData.photos.length > 0;
|
||||||
|
|
||||||
|
// 시공 완료 처리
|
||||||
|
const handleComplete = async () => {
|
||||||
|
try {
|
||||||
|
const result = await completeConstruction(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('시공이 완료되었습니다.');
|
||||||
|
router.push('/ko/construction/project/construction-management');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '시공 완료 처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to complete:', error);
|
||||||
|
toast.error('시공 완료 처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헤더 액션 - view/edit 모드에 따라 분리
|
||||||
|
const headerActions = isViewMode ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEdit}>수정</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>저장</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="시공 상세"
|
||||||
|
description="시공 정보를 확인하고 관리합니다"
|
||||||
|
icon={Wrench}
|
||||||
|
onBack={handleBack}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 시공 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">시공 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{/* 시공번호 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">시공번호</label>
|
||||||
|
<div className="font-medium">{detail.constructionNumber}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">현장</label>
|
||||||
|
<div className="font-medium">{detail.siteName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시공투입일 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">시공투입일</label>
|
||||||
|
<div className="font-medium">{formatDate(detail.constructionStartDate)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시공완료일 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">시공완료일</label>
|
||||||
|
<div className="font-medium">{formatDate(detail.constructionEndDate)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업반장 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">작업반장</label>
|
||||||
|
{isViewMode ? (
|
||||||
|
<div className="font-medium">{formData.workTeamLeader || '-'}</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={formData.workTeamLeader}
|
||||||
|
onValueChange={handleWorkTeamLeaderChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_CM_WORK_TEAM_LEADERS.map((leader) => (
|
||||||
|
<SelectItem key={leader.value} value={leader.label}>
|
||||||
|
{leader.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm text-muted-foreground">상태</label>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[detail.status]}`}
|
||||||
|
>
|
||||||
|
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[detail.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 작업자 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">작업자 정보</CardTitle>
|
||||||
|
{isEditMode && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddWorkerInfo}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{formData.workerInfoList.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
{isViewMode ? '등록된 작업자 정보가 없습니다.' : '작업자 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium w-40">작업일</th>
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium">작업자</th>
|
||||||
|
{isEditMode && (
|
||||||
|
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{formData.workerInfoList.map((worker, index) => (
|
||||||
|
<tr key={worker.id} className="border-b">
|
||||||
|
<td className="py-2 px-3">{index + 1}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isViewMode ? (
|
||||||
|
<span>{worker.workDate || '-'}</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={worker.workDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleWorkerInfoChange(worker.id, 'workDate', e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isViewMode ? (
|
||||||
|
<span>
|
||||||
|
{worker.workers.length > 0
|
||||||
|
? worker.workers
|
||||||
|
.map((w) => MOCK_EMPLOYEES.find((e) => e.value === w)?.label || w)
|
||||||
|
.join(', ')
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={MOCK_EMPLOYEES}
|
||||||
|
value={worker.workers}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleWorkerInfoChange(worker.id, 'workers', value)
|
||||||
|
}
|
||||||
|
placeholder="작업자 선택"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isEditMode && (
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteWorkerInfo(worker.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 공과 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-medium">공과 정보</CardTitle>
|
||||||
|
{isEditMode && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddWorkProgress}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{formData.workProgressList.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
{isViewMode ? '등록된 공과 정보가 없습니다.' : '공과 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium w-48">일정</th>
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-medium">공과명</th>
|
||||||
|
{isEditMode && (
|
||||||
|
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{formData.workProgressList.map((progress, index) => (
|
||||||
|
<tr key={progress.id} className="border-b">
|
||||||
|
<td className="py-2 px-3">{index + 1}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isViewMode ? (
|
||||||
|
<span>{progress.scheduleDate || '-'}</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={progress.scheduleDate.replace(' ', 'T')}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleWorkProgressChange(
|
||||||
|
progress.id,
|
||||||
|
'scheduleDate',
|
||||||
|
e.target.value.replace('T', ' ')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{isViewMode ? (
|
||||||
|
<span>{progress.workName || '-'}</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={progress.workName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleWorkProgressChange(progress.id, 'workName', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="공과명을 입력하세요"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isEditMode && (
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteWorkProgress(progress.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 발주서 영역 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">발주서</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleViewOrder}
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{detail.orderNumber}
|
||||||
|
</button>
|
||||||
|
<span className="text-muted-foreground text-sm">(클릭하여 발주서 보기)</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 이슈 목록 / 이슈 보고 - 카드 2개 형태 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 이슈 목록 카드 */}
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => router.push(`/ko/construction/project/issue-management?orderId=${detail.orderId}`)}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-6 pb-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-base font-medium">이슈 목록</h3>
|
||||||
|
<p className="text-3xl font-bold">{detail.issueCount}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 이슈 보고 카드 */}
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => router.push(`/ko/construction/project/issue-management/new?orderId=${detail.orderId}`)}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-6 pb-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-base font-medium">이슈 보고</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
이슈를 등록하시면 공사담당자가 검토합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업일지 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">작업일지</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{isViewMode ? (
|
||||||
|
<div className="min-h-[100px] whitespace-pre-wrap">
|
||||||
|
{formData.workLogContent || '등록된 작업일지가 없습니다.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={formData.workLogContent}
|
||||||
|
onChange={(e) => handleWorkLogChange(e.target.value)}
|
||||||
|
placeholder="작업일지를 입력하세요"
|
||||||
|
className="min-h-[150px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사진 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">사진</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 space-y-4">
|
||||||
|
{/* 업로드 버튼 - edit 모드에서만 */}
|
||||||
|
{isEditMode && (
|
||||||
|
<div>
|
||||||
|
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span className="text-sm">사진 업로드</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handlePhotoUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드된 사진 목록 */}
|
||||||
|
{formData.photos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{formData.photos.map((photo) => (
|
||||||
|
<div key={photo.id} className="relative group">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.name}
|
||||||
|
className="w-full h-32 object-cover rounded-lg border"
|
||||||
|
/>
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeletePhoto(photo.id)}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground truncate mt-1">
|
||||||
|
{photo.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.photos.length === 0 && (
|
||||||
|
<div className="text-center text-muted-foreground py-4">
|
||||||
|
업로드된 사진이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 시공 완료 버튼 - edit 모드에서만 */}
|
||||||
|
{isEditMode && detail.status === 'in_progress' && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setShowCompleteDialog(true)}
|
||||||
|
disabled={!canComplete}
|
||||||
|
>
|
||||||
|
시공 완료
|
||||||
|
</Button>
|
||||||
|
{!canComplete && (
|
||||||
|
<span className="ml-3 text-sm text-muted-foreground self-center">
|
||||||
|
* 작업일지와 사진을 모두 등록해야 시공 완료가 가능합니다.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 발주서 모달 */}
|
||||||
|
{orderData && (
|
||||||
|
<OrderDocumentModal
|
||||||
|
open={showOrderModal}
|
||||||
|
onOpenChange={setShowOrderModal}
|
||||||
|
order={orderData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 시공 완료 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>시공 완료</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
시공을 완료하시겠습니까? 완료 후에는 상태를 변경할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleComplete}>완료</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { HardHat, Pencil, Clock, CheckCircle } 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 { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { isSameDay, startOfDay, parseISO } from 'date-fns';
|
||||||
|
import type {
|
||||||
|
ConstructionManagement,
|
||||||
|
ConstructionManagementStats,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS,
|
||||||
|
CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
|
||||||
|
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
|
||||||
|
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
|
||||||
|
MOCK_CM_PARTNERS,
|
||||||
|
MOCK_CM_SITES,
|
||||||
|
MOCK_CM_CONSTRUCTION_PM,
|
||||||
|
MOCK_CM_WORK_TEAM_LEADERS,
|
||||||
|
getConstructionScheduleColor,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
getConstructionManagementList,
|
||||||
|
getConstructionManagementStats,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
// 체크박스, 번호, 시공번호, 거래처, 현장명, 공사PM, 작업반장, 작업자, 시공투입일, 시공완료일, 상태, 작업
|
||||||
|
const tableColumns: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||||
|
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
|
||||||
|
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||||
|
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||||
|
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
|
||||||
|
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
|
||||||
|
{ key: 'worker', label: '작업자', className: 'w-[80px]' },
|
||||||
|
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
|
||||||
|
{ key: 'constructionEndDate', label: '시공완료일', className: 'w-[100px]' },
|
||||||
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ConstructionManagementListClientProps {
|
||||||
|
initialData?: ConstructionManagement[];
|
||||||
|
initialStats?: ConstructionManagementStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstructionManagementListClient({
|
||||||
|
initialData = [],
|
||||||
|
initialStats,
|
||||||
|
}: ConstructionManagementListClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [constructions, setConstructions] = useState<ConstructionManagement[]>(initialData);
|
||||||
|
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// 다중선택 필터 (빈 배열 = 전체)
|
||||||
|
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||||
|
const [siteNameFilters, setSiteNameFilters] = useState<string[]>([]);
|
||||||
|
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||||
|
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||||
|
// 달력용 필터
|
||||||
|
const [calendarSiteFilters, setCalendarSiteFilters] = useState<string[]>([]);
|
||||||
|
const [calendarWorkTeamFilters, setCalendarWorkTeamFilters] = useState<string[]>([]);
|
||||||
|
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 [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
|
||||||
|
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||||
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getConstructionManagementList({
|
||||||
|
size: 1000,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
}),
|
||||||
|
getConstructionManagementStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setConstructions(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]);
|
||||||
|
|
||||||
|
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||||
|
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||||
|
[]);
|
||||||
|
const workTeamOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_CM_WORK_TEAM_LEADERS.map(l => ({ value: l.value, label: l.label })),
|
||||||
|
[]);
|
||||||
|
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_CM_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||||
|
[]);
|
||||||
|
const constructionPMOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_CM_CONSTRUCTION_PM.map(pm => ({ value: pm.value, label: pm.label })),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
// 달력용 이벤트 데이터 변환 (필터 적용)
|
||||||
|
// 색상: 작업반장별 고정 색상
|
||||||
|
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||||
|
return constructions
|
||||||
|
.filter((item) => {
|
||||||
|
// 현장 필터 (빈 배열 = 전체)
|
||||||
|
if (calendarSiteFilters.length > 0) {
|
||||||
|
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
|
||||||
|
if (!matchingSite || !calendarSiteFilters.includes(matchingSite.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업반장 필터 (빈 배열 = 전체)
|
||||||
|
if (calendarWorkTeamFilters.length > 0) {
|
||||||
|
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
|
||||||
|
if (!matchingLeader || !calendarWorkTeamFilters.includes(matchingLeader.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: `${item.workTeamLeader} - ${item.siteName} / ${item.constructionNumber}`,
|
||||||
|
startDate: item.periodStart,
|
||||||
|
endDate: item.periodEnd,
|
||||||
|
color: getConstructionScheduleColor(item.workTeamLeader),
|
||||||
|
status: item.status,
|
||||||
|
data: item,
|
||||||
|
}));
|
||||||
|
}, [constructions, calendarSiteFilters, calendarWorkTeamFilters]);
|
||||||
|
|
||||||
|
// 달력용 뱃지 데이터 - 사용하지 않음
|
||||||
|
const calendarBadges: DayBadge[] = [];
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredConstructions = useMemo(() => {
|
||||||
|
return constructions.filter((item) => {
|
||||||
|
// 상태 탭 필터
|
||||||
|
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
||||||
|
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (partnerFilters.length > 0) {
|
||||||
|
const matchingPartner = MOCK_CM_PARTNERS.find((p) => p.label === item.partnerName);
|
||||||
|
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장명 필터 (다중선택)
|
||||||
|
if (siteNameFilters.length > 0) {
|
||||||
|
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
|
||||||
|
if (!matchingSite || !siteNameFilters.includes(matchingSite.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공사PM 필터 (다중선택)
|
||||||
|
if (constructionPMFilters.length > 0) {
|
||||||
|
const matchingPM = MOCK_CM_CONSTRUCTION_PM.find((p) => p.label === item.constructionPM);
|
||||||
|
if (!matchingPM || !constructionPMFilters.includes(matchingPM.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업반장 필터 (다중선택)
|
||||||
|
if (workTeamFilters.length > 0) {
|
||||||
|
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
|
||||||
|
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 달력 날짜 필터 (시간 무시, 날짜만 비교)
|
||||||
|
if (selectedCalendarDate) {
|
||||||
|
const itemStart = startOfDay(parseISO(item.periodStart));
|
||||||
|
const itemEnd = startOfDay(parseISO(item.periodEnd));
|
||||||
|
const selected = startOfDay(selectedCalendarDate);
|
||||||
|
|
||||||
|
// 선택된 날짜가 기간 내에 있는지 확인
|
||||||
|
if (selected < itemStart || selected > itemEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchValue) {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.constructionNumber.toLowerCase().includes(search) ||
|
||||||
|
item.partnerName.toLowerCase().includes(search) ||
|
||||||
|
item.siteName.toLowerCase().includes(search) ||
|
||||||
|
item.workTeamLeader.toLowerCase().includes(search) ||
|
||||||
|
item.worker.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [constructions, activeStatTab, statusFilter, partnerFilters, siteNameFilters, constructionPMFilters, workTeamFilters, selectedCalendarDate, searchValue]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedConstructions = useMemo(() => {
|
||||||
|
const sorted = [...filteredConstructions];
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'latest':
|
||||||
|
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'register':
|
||||||
|
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'completionDateDesc':
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (!a.constructionEndDate) return 1;
|
||||||
|
if (!b.constructionEndDate) return -1;
|
||||||
|
return new Date(b.constructionEndDate).getTime() - new Date(a.constructionEndDate).getTime();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'partnerNameAsc':
|
||||||
|
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'partnerNameDesc':
|
||||||
|
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [filteredConstructions, sortBy]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(sortedConstructions.length / itemsPerPage);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedConstructions.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedConstructions, 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((c) => c.id)));
|
||||||
|
}
|
||||||
|
}, [selectedItems.size, paginatedData]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(item: ConstructionManagement) => {
|
||||||
|
router.push(`/ko/construction/project/construction-management/${item.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(e: React.MouseEvent, itemId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/ko/construction/project/construction-management/${itemId}/edit`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 달력 이벤트 핸들러
|
||||||
|
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||||
|
// 같은 날짜 클릭 시 선택 해제
|
||||||
|
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
|
||||||
|
setSelectedCalendarDate(null);
|
||||||
|
} else {
|
||||||
|
setSelectedCalendarDate(date);
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [selectedCalendarDate]);
|
||||||
|
|
||||||
|
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
||||||
|
if (event.data) {
|
||||||
|
router.push(`/ko/construction/project/construction-management/${event.id}`);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleCalendarMonthChange = useCallback((date: Date) => {
|
||||||
|
setCalendarDate(date);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return dateStr.split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
const renderTableRow = useCallback(
|
||||||
|
(item: ConstructionManagement, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(item.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{item.constructionNumber}</TableCell>
|
||||||
|
<TableCell>{item.partnerName}</TableCell>
|
||||||
|
<TableCell>{item.siteName}</TableCell>
|
||||||
|
<TableCell>{item.constructionPM}</TableCell>
|
||||||
|
<TableCell>{item.workTeamLeader}</TableCell>
|
||||||
|
<TableCell>{item.worker}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.constructionEndDate)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}`}>
|
||||||
|
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.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, item.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
const renderMobileCard = useCallback(
|
||||||
|
(item: ConstructionManagement, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
title={item.siteName}
|
||||||
|
subtitle={item.constructionNumber}
|
||||||
|
badge={CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
|
||||||
|
badgeVariant="secondary"
|
||||||
|
badgeClassName={CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
details={[
|
||||||
|
{ label: '거래처', value: item.partnerName },
|
||||||
|
{ label: '작업반장', value: item.workTeamLeader },
|
||||||
|
{ label: '작업자', value: item.worker || '-' },
|
||||||
|
{ label: '시공투입일', value: formatDate(item.constructionStartDate) },
|
||||||
|
{ label: '시공완료일', value: formatDate(item.constructionEndDate) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 헤더 액션 (DateRangeSelector)
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 통계 카드 클릭 핸들러
|
||||||
|
const handleStatClick = useCallback((tab: 'all' | 'in_progress' | 'completed') => {
|
||||||
|
setActiveStatTab(tab);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 통계 카드 데이터
|
||||||
|
const statsCardsData: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: '시공진행',
|
||||||
|
value: stats?.inProgress ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
onClick: () => handleStatClick('in_progress'),
|
||||||
|
isActive: activeStatTab === 'in_progress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '시공완료',
|
||||||
|
value: stats?.completed ?? 0,
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
onClick: () => handleStatClick('completed'),
|
||||||
|
isActive: activeStatTab === 'completed',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모바일 필터 설정
|
||||||
|
const mobileFilterFields: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'partners',
|
||||||
|
label: '거래처',
|
||||||
|
type: 'multi',
|
||||||
|
options: partnerOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sites',
|
||||||
|
label: '현장명',
|
||||||
|
type: 'multi',
|
||||||
|
options: siteOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'constructionPMs',
|
||||||
|
label: '공사PM',
|
||||||
|
type: 'multi',
|
||||||
|
options: constructionPMOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'workTeamLeaders',
|
||||||
|
label: '작업반장',
|
||||||
|
type: 'multi',
|
||||||
|
options: workTeamOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '상태',
|
||||||
|
type: 'single',
|
||||||
|
options: CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.filter(opt => opt.value !== 'all'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortBy',
|
||||||
|
label: '정렬',
|
||||||
|
type: 'single',
|
||||||
|
options: CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
|
||||||
|
allOptionLabel: '최신순',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모바일 필터 값
|
||||||
|
const mobileFilterValues: FilterValues = {
|
||||||
|
partners: partnerFilters,
|
||||||
|
sites: siteNameFilters,
|
||||||
|
constructionPMs: constructionPMFilters,
|
||||||
|
workTeamLeaders: workTeamFilters,
|
||||||
|
status: statusFilter,
|
||||||
|
sortBy: sortBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모바일 필터 변경 핸들러
|
||||||
|
const handleMobileFilterChange = (key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'partners':
|
||||||
|
setPartnerFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'sites':
|
||||||
|
setSiteNameFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'constructionPMs':
|
||||||
|
setConstructionPMFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'workTeamLeaders':
|
||||||
|
setWorkTeamFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
setStatusFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'sortBy':
|
||||||
|
setSortBy(value as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모바일 필터 초기화 핸들러
|
||||||
|
const handleMobileFilterReset = useCallback(() => {
|
||||||
|
setPartnerFilters([]);
|
||||||
|
setSiteNameFilters([]);
|
||||||
|
setConstructionPMFilters([]);
|
||||||
|
setWorkTeamFilters([]);
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSortBy('latest');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 헤더 액션 (기획서 요구사항)
|
||||||
|
// 거래처, 현장명, 공사PM, 작업반장, 상태, 정렬
|
||||||
|
const tableHeaderActions = (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end w-full">
|
||||||
|
{/* PC용 개별 필터 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 1. 거래처 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={partnerOptions}
|
||||||
|
value={partnerFilters}
|
||||||
|
onChange={setPartnerFilters}
|
||||||
|
placeholder="거래처"
|
||||||
|
searchPlaceholder="거래처 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. 현장명 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={siteOptions}
|
||||||
|
value={siteNameFilters}
|
||||||
|
onChange={setSiteNameFilters}
|
||||||
|
placeholder="현장명"
|
||||||
|
searchPlaceholder="현장명 검색..."
|
||||||
|
className="w-[140px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. 공사PM 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={constructionPMOptions}
|
||||||
|
value={constructionPMFilters}
|
||||||
|
onChange={setConstructionPMFilters}
|
||||||
|
placeholder="공사PM"
|
||||||
|
searchPlaceholder="공사PM 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. 작업반장 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={workTeamOptions}
|
||||||
|
value={workTeamFilters}
|
||||||
|
onChange={setWorkTeamFilters}
|
||||||
|
placeholder="작업반장"
|
||||||
|
searchPlaceholder="작업반장 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 5. 상태 필터 (단일선택) */}
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 6. 정렬 (단일선택) */}
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="정렬" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONSTRUCTION_MANAGEMENT_SORT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 달력 날짜 필터 초기화 */}
|
||||||
|
{selectedCalendarDate && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCalendarDate(null)}
|
||||||
|
>
|
||||||
|
날짜 필터 해제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 달력 필터 슬롯 (현장 + 작업반장 - 다중선택)
|
||||||
|
const calendarFilterSlot = (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 현장 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={siteOptions}
|
||||||
|
value={calendarSiteFilters}
|
||||||
|
onChange={setCalendarSiteFilters}
|
||||||
|
placeholder="현장"
|
||||||
|
searchPlaceholder="현장 검색..."
|
||||||
|
className="w-[160px]"
|
||||||
|
/>
|
||||||
|
{/* 작업반장 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={workTeamOptions}
|
||||||
|
value={calendarWorkTeamFilters}
|
||||||
|
onChange={setCalendarWorkTeamFilters}
|
||||||
|
placeholder="작업반장"
|
||||||
|
searchPlaceholder="작업반장 검색..."
|
||||||
|
className="w-[130px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="시공관리"
|
||||||
|
description="시공 스케줄 및 목록을 관리합니다"
|
||||||
|
icon={HardHat}
|
||||||
|
headerActions={headerActions}
|
||||||
|
stats={statsCardsData}
|
||||||
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
filterConfig={mobileFilterFields}
|
||||||
|
filterValues={mobileFilterValues}
|
||||||
|
onFilterChange={handleMobileFilterChange}
|
||||||
|
onFilterReset={handleMobileFilterReset}
|
||||||
|
filterTitle="시공관리 필터"
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchPlaceholder="시공번호, 거래처, 현장명, 작업반장, 작업자 검색"
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
data={paginatedData}
|
||||||
|
allData={sortedConstructions}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: sortedConstructions.length,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
// 달력 섹션 추가
|
||||||
|
beforeTableContent={
|
||||||
|
<div className="w-full flex-shrink-0 mb-6">
|
||||||
|
<ScheduleCalendar
|
||||||
|
events={calendarEvents}
|
||||||
|
badges={calendarBadges}
|
||||||
|
currentDate={calendarDate}
|
||||||
|
selectedDate={selectedCalendarDate}
|
||||||
|
onDateClick={handleCalendarDateClick}
|
||||||
|
onEventClick={handleCalendarEventClick}
|
||||||
|
onMonthChange={handleCalendarMonthChange}
|
||||||
|
titleSlot="시공 스케줄"
|
||||||
|
filterSlot={calendarFilterSlot}
|
||||||
|
maxEventsPerDay={5}
|
||||||
|
weekStartsOn={0}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type {
|
||||||
|
DetailCategory,
|
||||||
|
ConstructionItem,
|
||||||
|
IssueItem,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
DETAIL_CATEGORY_LABELS,
|
||||||
|
CONSTRUCTION_STATUS_LABELS,
|
||||||
|
ISSUE_STATUS_LABELS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface DetailAccordionProps {
|
||||||
|
categories: DetailCategory[];
|
||||||
|
selectedDetailId?: string | null;
|
||||||
|
onDetailSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailAccordion({
|
||||||
|
categories,
|
||||||
|
selectedDetailId,
|
||||||
|
onDetailSelect
|
||||||
|
}: DetailAccordionProps) {
|
||||||
|
// 첫 번째 카테고리만 기본 열림
|
||||||
|
const [openCategories, setOpenCategories] = useState<string[]>(
|
||||||
|
categories.length > 0 ? [categories[0].type] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCategory = (type: string) => {
|
||||||
|
setOpenCategories((prev) =>
|
||||||
|
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||||
|
상세 정보가 없습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryAccordionItem
|
||||||
|
key={category.type}
|
||||||
|
category={category}
|
||||||
|
isOpen={openCategories.includes(category.type)}
|
||||||
|
onToggle={() => toggleCategory(category.type)}
|
||||||
|
selectedDetailId={selectedDetailId}
|
||||||
|
onDetailSelect={onDetailSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryAccordionItemProps {
|
||||||
|
category: DetailCategory;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
selectedDetailId?: string | null;
|
||||||
|
onDetailSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryAccordionItem({
|
||||||
|
category,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
selectedDetailId,
|
||||||
|
onDetailSelect,
|
||||||
|
}: CategoryAccordionItemProps) {
|
||||||
|
const label = DETAIL_CATEGORY_LABELS[category.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden bg-card">
|
||||||
|
{/* 아코디언 헤더 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{category.count}건</p>
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 아코디언 컨텐츠 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t p-2 space-y-2 bg-muted/30 max-h-[300px] overflow-y-auto">
|
||||||
|
{category.type === 'construction' &&
|
||||||
|
category.constructionItems?.map((item) => (
|
||||||
|
<ConstructionCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={selectedDetailId === item.id}
|
||||||
|
onClick={() => onDetailSelect?.(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{category.type === 'issue' &&
|
||||||
|
category.issueItems?.map((item) => (
|
||||||
|
<IssueCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={selectedDetailId === item.id}
|
||||||
|
onClick={() => onDetailSelect?.(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstructionCardProps {
|
||||||
|
item: ConstructionItem;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConstructionCard({ item, isSelected, onClick }: ConstructionCardProps) {
|
||||||
|
const statusLabel = CONSTRUCTION_STATUS_LABELS[item.status];
|
||||||
|
const isInProgress = item.status === 'in_progress';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
|
||||||
|
isSelected && 'ring-2 ring-primary border-primary'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{item.number}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
시공투입일: {item.inputDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={isInProgress ? 'default' : 'secondary'}
|
||||||
|
className={cn(
|
||||||
|
'text-xs shrink-0',
|
||||||
|
isInProgress && 'bg-blue-500 hover:bg-blue-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueCardProps {
|
||||||
|
item: IssueItem;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueCard({ item, isSelected, onClick }: IssueCardProps) {
|
||||||
|
const statusLabel = ISSUE_STATUS_LABELS[item.status];
|
||||||
|
const isOpen = item.status === 'open';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
|
||||||
|
isSelected && 'ring-2 ring-primary border-primary'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{item.number}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.title}</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={isOpen ? 'destructive' : 'secondary'}
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { StageDetail, StageCardStatus } from './types';
|
||||||
|
import { DETAIL_CONFIG } from './types';
|
||||||
|
|
||||||
|
interface DetailCardProps {
|
||||||
|
detail: StageDetail;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailCard({ detail, onClick }: DetailCardProps) {
|
||||||
|
const config = DETAIL_CONFIG[detail.type];
|
||||||
|
|
||||||
|
// 상태 뱃지 색상
|
||||||
|
const getStatusBadge = (status?: StageCardStatus) => {
|
||||||
|
if (!status) return null;
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Badge className="text-xs bg-yellow-500">진행중</Badge>;
|
||||||
|
case 'waiting':
|
||||||
|
return <Badge variant="outline" className="text-xs">대기</Badge>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 헤더: 상세 타입 + 상태 뱃지 */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(detail.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
|
||||||
|
{detail.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* 날짜 또는 담당자 */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{detail.date && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>{config.dateLabel}</span>
|
||||||
|
<span>{detail.date.replace(/-/g, '.')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.pm && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>{config.dateLabel}</span>
|
||||||
|
<span>{detail.pm}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
headerAction?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanColumn({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
headerAction,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
emptyMessage = '항목이 없습니다.',
|
||||||
|
isEmpty = false,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col flex-1 min-w-0 bg-muted/30 rounded-lg', className)}>
|
||||||
|
{/* 컬럼 헤더 */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||||
|
{count !== undefined && (
|
||||||
|
<Badge className="text-xs bg-blue-500 hover:bg-blue-600">{count}건</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 컨텐츠 */}
|
||||||
|
<div className="flex-1 p-2 space-y-2 overflow-y-auto min-h-[500px] max-h-[calc(100vh-300px)]">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ProjectDetail, ProjectStatus } from './types';
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: ProjectDetail;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectCard({ project, isSelected, onClick }: ProjectCardProps) {
|
||||||
|
// 상태 뱃지 색상
|
||||||
|
const getStatusBadge = (status: ProjectStatus, hasUrgentIssue: boolean) => {
|
||||||
|
if (hasUrgentIssue) {
|
||||||
|
return <Badge variant="destructive" className="text-xs">긴급</Badge>;
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Badge className="text-xs bg-blue-500">진행</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline" className="text-xs">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return amount.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
|
||||||
|
isSelected && 'ring-2 ring-primary border-primary'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 헤더: 현장명 + 상태 뱃지 */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-foreground line-clamp-1">
|
||||||
|
{project.siteName}
|
||||||
|
</h4>
|
||||||
|
{getStatusBadge(project.status, project.hasUrgentIssue)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>진행률</span>
|
||||||
|
<span className="font-medium text-foreground">{project.progressRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
project.status === 'completed' ? 'bg-gray-400' :
|
||||||
|
project.hasUrgentIssue ? 'bg-red-500' : 'bg-blue-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${project.progressRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>{project.partnerName}</span>
|
||||||
|
<span className="font-medium text-foreground">{project.totalLocations}개소</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>계약금</span>
|
||||||
|
<span className="font-medium text-foreground">{formatAmount(project.contractAmount)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>기간</span>
|
||||||
|
<span>{project.startDate.replace(/-/g, '.')} ~ {project.endDate.replace(/-/g, '.')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { FolderKanban, ClipboardList, PlayCircle, CheckCircle2, Search } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||||
|
import ProjectKanbanBoard from './ProjectKanbanBoard';
|
||||||
|
import ProjectEndDialog from './ProjectEndDialog';
|
||||||
|
import type { ProjectDetail, ProjectStats, SelectOption } from './types';
|
||||||
|
import { getProjectsForKanban, getProjectStats, getPartnerOptions, getSiteOptions } from './actions';
|
||||||
|
|
||||||
|
interface ProjectDetailClientProps {
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailClient({ projectId }: ProjectDetailClientProps) {
|
||||||
|
// 데이터 상태
|
||||||
|
const [projects, setProjects] = useState<ProjectDetail[]>([]);
|
||||||
|
const [stats, setStats] = useState<ProjectStats>({ total: 0, inProgress: 0, completed: 0 });
|
||||||
|
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
|
||||||
|
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [filterStartDate, setFilterStartDate] = useState(() =>
|
||||||
|
format(startOfMonth(new Date()), 'yyyy-MM-dd')
|
||||||
|
);
|
||||||
|
const [filterEndDate, setFilterEndDate] = useState(() =>
|
||||||
|
format(endOfMonth(new Date()), 'yyyy-MM-dd')
|
||||||
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// 프로젝트 종료 다이얼로그 상태
|
||||||
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<ProjectDetail | null>(null);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [projectsResult, statsResult, partnersResult, sitesResult] = await Promise.all([
|
||||||
|
getProjectsForKanban(),
|
||||||
|
getProjectStats(),
|
||||||
|
getPartnerOptions(),
|
||||||
|
getSiteOptions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (projectsResult.success && projectsResult.data) {
|
||||||
|
setProjects(projectsResult.data);
|
||||||
|
}
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
setStats(statsResult.data);
|
||||||
|
}
|
||||||
|
if (partnersResult.success && partnersResult.data) {
|
||||||
|
setPartnerOptions(partnersResult.data);
|
||||||
|
}
|
||||||
|
if (sitesResult.success && sitesResult.data) {
|
||||||
|
setSiteOptions(sitesResult.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('데이터 로드에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 검색 필터링된 프로젝트
|
||||||
|
const filteredProjects = projects.filter((project) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
project.siteName.toLowerCase().includes(query) ||
|
||||||
|
project.partnerName.toLowerCase().includes(query) ||
|
||||||
|
project.contractNumber.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로젝트 종료 버튼 클릭 핸들러
|
||||||
|
const handleProjectEndClick = (project: ProjectDetail) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setEndDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로젝트 종료 성공 핸들러
|
||||||
|
const handleEndSuccess = () => {
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<PageHeader
|
||||||
|
title="프로젝트 실행 관리"
|
||||||
|
description="프로젝트 실행 관리(제안서)"
|
||||||
|
icon={FolderKanban}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={filterStartDate}
|
||||||
|
endDate={filterEndDate}
|
||||||
|
onStartDateChange={setFilterStartDate}
|
||||||
|
onEndDateChange={setFilterEndDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 상태 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">전체 프로젝트</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-100 rounded-lg">
|
||||||
|
<PlayCircle className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">프로젝트 진행</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.inProgress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 rounded-lg">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">프로젝트 완료</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.completed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 칸반 보드 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 min-h-[600px]">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-[500px]">
|
||||||
|
<p className="text-muted-foreground">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ProjectKanbanBoard
|
||||||
|
projects={filteredProjects}
|
||||||
|
partnerOptions={partnerOptions}
|
||||||
|
siteOptions={siteOptions}
|
||||||
|
onProjectEndClick={handleProjectEndClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 프로젝트 종료 다이얼로그 */}
|
||||||
|
<ProjectEndDialog
|
||||||
|
open={endDialogOpen}
|
||||||
|
onOpenChange={setEndDialogOpen}
|
||||||
|
project={selectedProject}
|
||||||
|
onSuccess={handleEndSuccess}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ProjectDetail, ProjectEndFormData } from './types';
|
||||||
|
import { PROJECT_END_STATUS_OPTIONS } from './types';
|
||||||
|
import { updateProjectEnd } from './actions';
|
||||||
|
|
||||||
|
interface ProjectEndDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
project: ProjectDetail | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectEndDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
project,
|
||||||
|
onSuccess,
|
||||||
|
}: ProjectEndDialogProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<ProjectEndFormData>({
|
||||||
|
projectId: '',
|
||||||
|
projectName: '',
|
||||||
|
workDate: '',
|
||||||
|
completionDate: '',
|
||||||
|
status: 'in_progress',
|
||||||
|
memo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로젝트가 변경되면 폼 데이터 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (project) {
|
||||||
|
setFormData({
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.siteName,
|
||||||
|
workDate: project.endDate, // 결선작업일은 프로젝트 종료일로 설정
|
||||||
|
completionDate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||||
|
status: project.status === 'completed' ? 'completed' : 'in_progress',
|
||||||
|
memo: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
// 수정 버튼 클릭
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await updateProjectEnd(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('프로젝트 종료 처리가 완료되었습니다.');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '처리 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('처리 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 버튼 클릭
|
||||||
|
const handleDelete = () => {
|
||||||
|
toast.info('삭제 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>프로젝트 종료</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{/* 프로젝트 (현장명) - 읽기전용 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="projectName">프로젝트</Label>
|
||||||
|
<Input
|
||||||
|
id="projectName"
|
||||||
|
value={formData.projectName}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결선작업일 - 읽기전용 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="workDate">결선작업일</Label>
|
||||||
|
<Input
|
||||||
|
id="workDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.workDate}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결선완료일 - 입력 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="completionDate">결선완료일</Label>
|
||||||
|
<Input
|
||||||
|
id="completionDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.completionDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, completionDate: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 - 셀렉트 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="status">상태</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(value: 'in_progress' | 'completed') =>
|
||||||
|
setFormData((prev) => ({ ...prev, status: value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="상태 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROJECT_END_STATUS_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메모 - 텍스트에어리어 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="memo">메모</Label>
|
||||||
|
<Textarea
|
||||||
|
id="memo"
|
||||||
|
placeholder="메모를 입력해주세요."
|
||||||
|
value={formData.memo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, memo: e.target.value }))
|
||||||
|
}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-24"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-24"
|
||||||
|
>
|
||||||
|
{isSubmitting ? '처리중...' : '수정'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import KanbanColumn from './KanbanColumn';
|
||||||
|
import ProjectCard from './ProjectCard';
|
||||||
|
import StageCard from './StageCard';
|
||||||
|
import DetailAccordion from './DetailAccordion';
|
||||||
|
import type { ProjectDetail, Stage, DetailCategory, SelectOption } from './types';
|
||||||
|
import { STAGE_LABELS } from './types';
|
||||||
|
import { getDetailCategories } from './actions';
|
||||||
|
|
||||||
|
interface ProjectKanbanBoardProps {
|
||||||
|
projects: ProjectDetail[];
|
||||||
|
partnerOptions?: SelectOption[];
|
||||||
|
siteOptions?: SelectOption[];
|
||||||
|
onProjectEndClick?: (project: ProjectDetail) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectKanbanBoard({
|
||||||
|
projects,
|
||||||
|
partnerOptions = [],
|
||||||
|
siteOptions = [],
|
||||||
|
onProjectEndClick,
|
||||||
|
}: ProjectKanbanBoardProps) {
|
||||||
|
// 필터 상태
|
||||||
|
const [selectedPartner, setSelectedPartner] = useState<string>('all');
|
||||||
|
const [selectedSite, setSelectedSite] = useState<string>('all');
|
||||||
|
|
||||||
|
// 선택된 프로젝트
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||||
|
// 선택된 단계
|
||||||
|
const [selectedStageId, setSelectedStageId] = useState<string | null>(null);
|
||||||
|
// 상세 카테고리 (시공, 이슈 아코디언)
|
||||||
|
const [detailCategories, setDetailCategories] = useState<DetailCategory[]>([]);
|
||||||
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
|
// 선택된 상세 아이템
|
||||||
|
const [selectedDetailId, setSelectedDetailId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 필터링된 프로젝트
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
let result = [...projects];
|
||||||
|
|
||||||
|
if (selectedPartner !== 'all') {
|
||||||
|
result = result.filter((p) => p.partnerName === selectedPartner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSite !== 'all') {
|
||||||
|
result = result.filter((p) => p.siteName === selectedSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [projects, selectedPartner, selectedSite]);
|
||||||
|
|
||||||
|
// 선택된 프로젝트 정보
|
||||||
|
const selectedProject = useMemo(() => {
|
||||||
|
return filteredProjects.find((p) => p.id === selectedProjectId) || null;
|
||||||
|
}, [filteredProjects, selectedProjectId]);
|
||||||
|
|
||||||
|
// 선택된 프로젝트의 단계 목록
|
||||||
|
const stages = useMemo(() => {
|
||||||
|
if (!selectedProject) return [];
|
||||||
|
return selectedProject.stages || [];
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
// 선택된 단계 정보
|
||||||
|
const selectedStage = useMemo(() => {
|
||||||
|
return stages.find((s) => s.id === selectedStageId) || null;
|
||||||
|
}, [stages, selectedStageId]);
|
||||||
|
|
||||||
|
// 단계 선택 시 상세 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedStageId) {
|
||||||
|
setDetailCategories([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
setIsLoadingCategories(true);
|
||||||
|
try {
|
||||||
|
const result = await getDetailCategories(selectedStageId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDetailCategories(result.data);
|
||||||
|
} else {
|
||||||
|
setDetailCategories([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDetailCategories([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCategories(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategories();
|
||||||
|
}, [selectedStageId]);
|
||||||
|
|
||||||
|
// 프로젝트 선택 핸들러
|
||||||
|
const handleProjectClick = (project: ProjectDetail) => {
|
||||||
|
if (selectedProjectId === project.id) {
|
||||||
|
// 이미 선택된 프로젝트 클릭 시 선택 해제
|
||||||
|
setSelectedProjectId(null);
|
||||||
|
setSelectedStageId(null);
|
||||||
|
} else {
|
||||||
|
setSelectedProjectId(project.id);
|
||||||
|
setSelectedStageId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계 선택 핸들러
|
||||||
|
const handleStageClick = (stage: Stage) => {
|
||||||
|
if (selectedStageId === stage.id) {
|
||||||
|
setSelectedStageId(null);
|
||||||
|
} else {
|
||||||
|
setSelectedStageId(stage.id);
|
||||||
|
}
|
||||||
|
// 단계 변경 시 상세 선택 초기화
|
||||||
|
setSelectedDetailId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상세 아이템 선택 핸들러
|
||||||
|
const handleDetailSelect = (id: string) => {
|
||||||
|
if (selectedDetailId === id) {
|
||||||
|
setSelectedDetailId(null);
|
||||||
|
} else {
|
||||||
|
setSelectedDetailId(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로젝트 종료 버튼 클릭 핸들러
|
||||||
|
const handleProjectEndClick = () => {
|
||||||
|
if (selectedProject && onProjectEndClick) {
|
||||||
|
onProjectEndClick(selectedProject);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Select value={selectedPartner} onValueChange={setSelectedPartner}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{partnerOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={selectedSite} onValueChange={setSelectedSite}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{siteOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 칸반 보드 */}
|
||||||
|
<div className="flex gap-4 w-full">
|
||||||
|
{/* 프로젝트 컬럼 */}
|
||||||
|
<KanbanColumn
|
||||||
|
title="프로젝트"
|
||||||
|
count={filteredProjects.length}
|
||||||
|
isEmpty={filteredProjects.length === 0}
|
||||||
|
emptyMessage="프로젝트가 없습니다."
|
||||||
|
>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
isSelected={selectedProjectId === project.id}
|
||||||
|
onClick={() => handleProjectClick(project)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</KanbanColumn>
|
||||||
|
|
||||||
|
{/* 단계 컬럼 */}
|
||||||
|
<KanbanColumn
|
||||||
|
title="단계"
|
||||||
|
headerAction={
|
||||||
|
selectedProject && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleProjectEndClick}
|
||||||
|
>
|
||||||
|
프로젝트 종료
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isEmpty={stages.length === 0}
|
||||||
|
emptyMessage={selectedProjectId ? '단계가 없습니다.' : '프로젝트를 선택하세요.'}
|
||||||
|
>
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<StageCard
|
||||||
|
key={stage.id}
|
||||||
|
stage={stage}
|
||||||
|
isSelected={selectedStageId === stage.id}
|
||||||
|
onClick={() => handleStageClick(stage)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</KanbanColumn>
|
||||||
|
|
||||||
|
{/* 상세 컬럼 */}
|
||||||
|
<KanbanColumn
|
||||||
|
title="상세"
|
||||||
|
isEmpty={!selectedStageId && detailCategories.length === 0}
|
||||||
|
emptyMessage={selectedStageId ? '상세 항목이 없습니다.' : '단계를 선택하세요.'}
|
||||||
|
>
|
||||||
|
{isLoadingCategories ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DetailAccordion
|
||||||
|
categories={detailCategories}
|
||||||
|
selectedDetailId={selectedDetailId}
|
||||||
|
onDetailSelect={handleDetailSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</KanbanColumn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Stage, StageCardStatus } from './types';
|
||||||
|
import { STAGE_LABELS, STAGE_CARD_STATUS_LABELS } from './types';
|
||||||
|
|
||||||
|
interface StageCardProps {
|
||||||
|
stage: Stage;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StageCard({ stage, isSelected, onClick }: StageCardProps) {
|
||||||
|
// 상태 뱃지 색상
|
||||||
|
const getStatusBadge = (status: StageCardStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Badge className="text-xs bg-yellow-500">진행중</Badge>;
|
||||||
|
case 'waiting':
|
||||||
|
return <Badge variant="outline" className="text-xs">대기</Badge>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return amount.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
|
||||||
|
isSelected && 'ring-2 ring-primary border-primary'
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 헤더: 단계명 + 상태 뱃지 */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{STAGE_LABELS[stage.type]}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(stage.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장명 */}
|
||||||
|
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
|
||||||
|
{stage.siteName}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* 세부 정보 */}
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
{stage.date && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>날짜</span>
|
||||||
|
<span>{stage.date.replace(/-/g, '.')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stage.amount && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>금액</span>
|
||||||
|
<span className="font-medium text-foreground">{formatAmount(stage.amount)}원</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stage.count && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>개소</span>
|
||||||
|
<span>{stage.count}개소</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stage.pm && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>담당PM</span>
|
||||||
|
<span>{stage.pm}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import type { Project, ProjectStats, ProjectFilter, ProjectListResponse, SelectOption } from './types';
|
import type {
|
||||||
|
Project,
|
||||||
|
ProjectStats,
|
||||||
|
ProjectFilter,
|
||||||
|
ProjectListResponse,
|
||||||
|
SelectOption,
|
||||||
|
ProjectDetail,
|
||||||
|
Stage,
|
||||||
|
StageDetail,
|
||||||
|
ProjectEndFormData,
|
||||||
|
DetailCategory,
|
||||||
|
ConstructionItem,
|
||||||
|
IssueItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로젝트 관리 Server Actions
|
* 프로젝트 관리 Server Actions
|
||||||
@@ -426,4 +439,785 @@ export async function updateProject(
|
|||||||
console.error('updateProject error:', error);
|
console.error('updateProject error:', error);
|
||||||
return { success: false, error: '프로젝트 수정에 실패했습니다.' };
|
return { success: false, error: '프로젝트 수정에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 프로젝트 실행관리 상세 페이지 Server Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 프로젝트별 단계 목업 데이터
|
||||||
|
const mockStages: Record<string, Stage[]> = {
|
||||||
|
'1': [
|
||||||
|
{ id: 's1-1', type: 'bid', siteName: '서울 강남 현장', status: 'completed', date: '2024-11-05', amount: 150000000 },
|
||||||
|
{ id: 's1-2', type: 'contract', siteName: '서울 강남 현장', status: 'completed', date: '2024-11-10', amount: 150000000 },
|
||||||
|
{ id: 's1-3', type: 'construction', siteName: '서울 강남 현장', status: 'completed', pm: '김철수', count: 5 },
|
||||||
|
],
|
||||||
|
'2': [
|
||||||
|
{ id: 's2-1', type: 'bid', siteName: '부산 해운대 현장', status: 'completed', date: '2024-12-18', amount: 200000000 },
|
||||||
|
{ id: 's2-2', type: 'contract', siteName: '부산 해운대 현장', status: 'completed', date: '2024-12-20', amount: 200000000 },
|
||||||
|
{ id: 's2-3', type: 'construction', siteName: '부산 해운대 현장', status: 'in_progress', pm: '박민수', count: 8 },
|
||||||
|
],
|
||||||
|
'3': [
|
||||||
|
{ id: 's3-1', type: 'bid', siteName: '대전 유성 현장', status: 'in_progress', date: '2025-01-05', amount: 80000000 },
|
||||||
|
],
|
||||||
|
'4': [
|
||||||
|
{ id: 's4-1', type: 'bid', siteName: '인천 송도 현장', status: 'completed', date: '2024-10-05', amount: 350000000 },
|
||||||
|
{ id: 's4-2', type: 'contract', siteName: '인천 송도 현장', status: 'completed', date: '2024-10-10', amount: 350000000 },
|
||||||
|
{ id: 's4-3', type: 'order', siteName: '인천 송도 현장', status: 'completed', date: '2024-10-15' },
|
||||||
|
{ id: 's4-4', type: 'construction', siteName: '인천 송도 현장', status: 'in_progress', pm: '윤대리', count: 12 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계별 상세 항목 목업 데이터
|
||||||
|
const mockStageDetails: Record<string, StageDetail[]> = {
|
||||||
|
's2-1': [
|
||||||
|
{ id: 'd2-1-1', type: 'site_briefing', title: '부산 해운대 현장', date: '2024-12-16', status: 'completed' },
|
||||||
|
{ id: 'd2-1-2', type: 'estimation', title: '부산 해운대 현장', date: '2024-12-17', status: 'completed' },
|
||||||
|
{ id: 'd2-1-3', type: 'bid_result', title: '부산 해운대 현장', date: '2024-12-18', status: 'completed' },
|
||||||
|
],
|
||||||
|
's2-3': [
|
||||||
|
{ id: 'd2-3-1', type: 'handover_report', title: '부산 해운대 현장', pm: '박민수', status: 'completed' },
|
||||||
|
{ id: 'd2-3-2', type: 'structure_review', title: '부산 해운대 현장', date: '2024-12-22', status: 'completed' },
|
||||||
|
],
|
||||||
|
's3-1': [
|
||||||
|
{ id: 'd3-1-1', type: 'site_briefing', title: '대전 유성 현장', date: '2025-01-03', status: 'completed' },
|
||||||
|
{ id: 'd3-1-2', type: 'estimation', title: '대전 유성 현장', date: '2025-01-04', status: 'in_progress' },
|
||||||
|
],
|
||||||
|
's4-1': [
|
||||||
|
{ id: 'd4-1-1', type: 'site_briefing', title: '인천 송도 현장', date: '2024-10-03', status: 'completed' },
|
||||||
|
{ id: 'd4-1-2', type: 'estimation', title: '인천 송도 현장', date: '2024-10-04', status: 'completed' },
|
||||||
|
{ id: 'd4-1-3', type: 'bid_result', title: '인천 송도 현장', date: '2024-10-05', status: 'completed' },
|
||||||
|
],
|
||||||
|
's4-4': [
|
||||||
|
{ id: 'd4-4-1', type: 'handover_report', title: '인천 송도 현장', pm: '윤대리', status: 'completed' },
|
||||||
|
{ id: 'd4-4-2', type: 'structure_review', title: '인천 송도 현장', date: '2024-10-18', status: 'completed' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프로젝트 상세 조회 (칸반 보드용)
|
||||||
|
export async function getProjectDetail(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; data?: ProjectDetail; error?: string }> {
|
||||||
|
try {
|
||||||
|
const project = mockProjects.find((p) => p.id === id);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return { success: false, error: '프로젝트를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = mockStages[id] || [];
|
||||||
|
const details: StageDetail[] = [];
|
||||||
|
|
||||||
|
// 각 단계의 상세 항목 수집
|
||||||
|
stages.forEach((stage) => {
|
||||||
|
const stageDetails = mockStageDetails[stage.id] || [];
|
||||||
|
details.push(...stageDetails);
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectDetail: ProjectDetail = {
|
||||||
|
...project,
|
||||||
|
stages,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { success: true, data: projectDetail };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getProjectDetail error:', error);
|
||||||
|
return { success: false, error: '프로젝트 상세 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단계별 상세 항목 조회
|
||||||
|
export async function getStageDetails(
|
||||||
|
stageId: string
|
||||||
|
): Promise<{ success: boolean; data?: StageDetail[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const details = mockStageDetails[stageId] || [];
|
||||||
|
return { success: true, data: details };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getStageDetails error:', error);
|
||||||
|
return { success: false, error: '단계 상세 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 종료 처리
|
||||||
|
export async function updateProjectEnd(
|
||||||
|
data: ProjectEndFormData
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Project end:', data);
|
||||||
|
|
||||||
|
const project = mockProjects.find((p) => p.id === data.projectId);
|
||||||
|
if (!project) {
|
||||||
|
return { success: false, error: '프로젝트를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 구현에서는 DB 업데이트
|
||||||
|
// project.status = data.status === 'completed' ? 'completed' : 'in_progress';
|
||||||
|
// project.endDate = data.completionDate;
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateProjectEnd error:', error);
|
||||||
|
return { success: false, error: '프로젝트 종료 처리에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단계별 상세 카테고리 목업 데이터 (시공, 이슈 아코디언)
|
||||||
|
const mockDetailCategories: Record<string, DetailCategory[]> = {
|
||||||
|
// 시공 단계 선택 시 표시할 상세 카테고리
|
||||||
|
's1-3': [
|
||||||
|
{
|
||||||
|
type: 'construction',
|
||||||
|
count: 3,
|
||||||
|
constructionItems: [
|
||||||
|
{ id: 'c1-1', number: '123123', inputDate: '2024-11-15', status: 'completed' },
|
||||||
|
{ id: 'c1-2', number: '123124', inputDate: '2024-11-20', status: 'completed' },
|
||||||
|
{ id: 'c1-3', number: '123125', inputDate: '2024-11-25', status: 'completed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'issue',
|
||||||
|
count: 2,
|
||||||
|
issueItems: [
|
||||||
|
{ id: 'i1-1', number: 'ISS-001', title: '자재 지연', status: 'resolved', createdAt: '2024-11-18' },
|
||||||
|
{ id: 'i1-2', number: 'ISS-002', title: '인력 부족', status: 'resolved', createdAt: '2024-11-22' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
's2-3': [
|
||||||
|
{
|
||||||
|
type: 'construction',
|
||||||
|
count: 5,
|
||||||
|
constructionItems: [
|
||||||
|
{ id: 'c2-1', number: '123126', inputDate: '2024-12-25', status: 'in_progress' },
|
||||||
|
{ id: 'c2-2', number: '123127', inputDate: '2024-12-28', status: 'completed' },
|
||||||
|
{ id: 'c2-3', number: '123128', inputDate: '2025-01-02', status: 'completed' },
|
||||||
|
{ id: 'c2-4', number: '123129', inputDate: '2025-01-05', status: 'in_progress' },
|
||||||
|
{ id: 'c2-5', number: '123130', inputDate: '2025-01-08', status: 'in_progress' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'issue',
|
||||||
|
count: 3,
|
||||||
|
issueItems: [
|
||||||
|
{ id: 'i2-1', number: 'ISS-003', title: '설계 변경', status: 'open', createdAt: '2024-12-27' },
|
||||||
|
{ id: 'i2-2', number: 'ISS-004', title: '민원 발생', status: 'resolved', createdAt: '2025-01-03' },
|
||||||
|
{ id: 'i2-3', number: 'ISS-005', title: '안전 점검', status: 'open', createdAt: '2025-01-10' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
's4-4': [
|
||||||
|
{
|
||||||
|
type: 'construction',
|
||||||
|
count: 128,
|
||||||
|
constructionItems: [
|
||||||
|
{ id: 'c4-1', number: '123123', inputDate: '2025-12-15', status: 'in_progress' },
|
||||||
|
{ id: 'c4-2', number: '123123', inputDate: '2025-12-15', status: 'completed' },
|
||||||
|
{ id: 'c4-3', number: '123123', inputDate: '2025-12-15', status: 'completed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'issue',
|
||||||
|
count: 128,
|
||||||
|
issueItems: [
|
||||||
|
{ id: 'i4-1', number: 'ISS-010', title: '품질 검사', status: 'open', createdAt: '2024-10-20' },
|
||||||
|
{ id: 'i4-2', number: 'ISS-011', title: '공정 지연', status: 'resolved', createdAt: '2024-10-25' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계별 상세 카테고리 조회
|
||||||
|
export async function getDetailCategories(
|
||||||
|
stageId: string
|
||||||
|
): Promise<{ success: boolean; data?: DetailCategory[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const categories = mockDetailCategories[stageId] || [];
|
||||||
|
return { success: true, data: categories };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getDetailCategories error:', error);
|
||||||
|
return { success: false, error: '상세 카테고리 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 목록 조회 (상세 페이지용 - 전체 프로젝트 목록)
|
||||||
|
export async function getProjectsForKanban(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: ProjectDetail[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const projectDetails: ProjectDetail[] = mockProjects.map((project) => {
|
||||||
|
const stages = mockStages[project.id] || [];
|
||||||
|
const details: StageDetail[] = [];
|
||||||
|
|
||||||
|
stages.forEach((stage) => {
|
||||||
|
const stageDetails = mockStageDetails[stage.id] || [];
|
||||||
|
details.push(...stageDetails);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
stages,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: projectDetails };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getProjectsForKanban error:', error);
|
||||||
|
return { success: false, error: '프로젝트 목록 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 시공관리 리스트 Server Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConstructionManagement,
|
||||||
|
ConstructionManagementStats,
|
||||||
|
ConstructionManagementFilter,
|
||||||
|
ConstructionManagementListResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 시공관리 목업 데이터
|
||||||
|
const mockConstructionManagements: ConstructionManagement[] = [
|
||||||
|
{
|
||||||
|
id: 'cm1',
|
||||||
|
constructionNumber: '121212',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '김철수',
|
||||||
|
workTeamLeader: '이반장',
|
||||||
|
worker: '홍길동',
|
||||||
|
workerCount: 3,
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-15',
|
||||||
|
periodEnd: '2025-12-20',
|
||||||
|
createdAt: '2025-12-10',
|
||||||
|
updatedAt: '2025-12-15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm2',
|
||||||
|
constructionNumber: '121213',
|
||||||
|
partnerName: '삼성시공',
|
||||||
|
siteName: '부산 해운대 현장',
|
||||||
|
constructionPM: '박민수',
|
||||||
|
workTeamLeader: '김반장',
|
||||||
|
worker: '이순신',
|
||||||
|
workerCount: 2,
|
||||||
|
constructionStartDate: '2025-12-14',
|
||||||
|
constructionEndDate: '2025-12-16',
|
||||||
|
status: 'completed',
|
||||||
|
periodStart: '2025-12-14',
|
||||||
|
periodEnd: '2025-12-16',
|
||||||
|
createdAt: '2025-12-08',
|
||||||
|
updatedAt: '2025-12-16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm3',
|
||||||
|
constructionNumber: '121214',
|
||||||
|
partnerName: 'LG건설',
|
||||||
|
siteName: '대전 유성 현장',
|
||||||
|
constructionPM: '정대리',
|
||||||
|
workTeamLeader: '박반장',
|
||||||
|
worker: '강감찬',
|
||||||
|
workerCount: 4,
|
||||||
|
constructionStartDate: '2025-12-13',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-13',
|
||||||
|
periodEnd: '2025-12-18',
|
||||||
|
createdAt: '2025-12-05',
|
||||||
|
updatedAt: '2025-12-13',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm4',
|
||||||
|
constructionNumber: '121215',
|
||||||
|
partnerName: '현대건설',
|
||||||
|
siteName: '인천 송도 현장',
|
||||||
|
constructionPM: '윤대리',
|
||||||
|
workTeamLeader: '최반장',
|
||||||
|
worker: '을지문덕',
|
||||||
|
workerCount: 5,
|
||||||
|
constructionStartDate: '2025-12-12',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
status: 'completed',
|
||||||
|
periodStart: '2025-12-12',
|
||||||
|
periodEnd: '2025-12-15',
|
||||||
|
createdAt: '2025-12-01',
|
||||||
|
updatedAt: '2025-12-15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm5',
|
||||||
|
constructionNumber: '121216',
|
||||||
|
partnerName: 'SK건설',
|
||||||
|
siteName: '광주 북구 현장',
|
||||||
|
constructionPM: '오차장',
|
||||||
|
workTeamLeader: '정반장',
|
||||||
|
worker: '계백',
|
||||||
|
workerCount: 2,
|
||||||
|
constructionStartDate: '2025-12-11',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-11',
|
||||||
|
periodEnd: '2025-12-19',
|
||||||
|
createdAt: '2025-12-02',
|
||||||
|
updatedAt: '2025-12-11',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm6',
|
||||||
|
constructionNumber: '121217',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '김철수',
|
||||||
|
workTeamLeader: '이반장',
|
||||||
|
worker: '김유신',
|
||||||
|
workerCount: 3,
|
||||||
|
constructionStartDate: '2025-12-10',
|
||||||
|
constructionEndDate: '2025-12-14',
|
||||||
|
status: 'completed',
|
||||||
|
periodStart: '2025-12-10',
|
||||||
|
periodEnd: '2025-12-14',
|
||||||
|
createdAt: '2025-12-03',
|
||||||
|
updatedAt: '2025-12-14',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm7',
|
||||||
|
constructionNumber: '121218',
|
||||||
|
partnerName: '삼성시공',
|
||||||
|
siteName: '부산 해운대 현장',
|
||||||
|
constructionPM: '박민수',
|
||||||
|
workTeamLeader: '김반장',
|
||||||
|
worker: '권율',
|
||||||
|
workerCount: 4,
|
||||||
|
constructionStartDate: '2025-12-09',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-09',
|
||||||
|
periodEnd: '2025-12-17',
|
||||||
|
createdAt: '2025-12-04',
|
||||||
|
updatedAt: '2025-12-09',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm8',
|
||||||
|
constructionNumber: '121219',
|
||||||
|
partnerName: 'LG건설',
|
||||||
|
siteName: '대전 유성 현장',
|
||||||
|
constructionPM: '정대리',
|
||||||
|
workTeamLeader: '박반장',
|
||||||
|
worker: '이성계',
|
||||||
|
workerCount: 3,
|
||||||
|
constructionStartDate: '2025-12-08',
|
||||||
|
constructionEndDate: '2025-12-12',
|
||||||
|
status: 'completed',
|
||||||
|
periodStart: '2025-12-08',
|
||||||
|
periodEnd: '2025-12-12',
|
||||||
|
createdAt: '2025-12-01',
|
||||||
|
updatedAt: '2025-12-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm9',
|
||||||
|
constructionNumber: '121220',
|
||||||
|
partnerName: '현대건설',
|
||||||
|
siteName: '인천 송도 현장',
|
||||||
|
constructionPM: '윤대리',
|
||||||
|
workTeamLeader: '최반장',
|
||||||
|
worker: '정도전',
|
||||||
|
workerCount: 6,
|
||||||
|
constructionStartDate: '2025-12-07',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-07',
|
||||||
|
periodEnd: '2025-12-16',
|
||||||
|
createdAt: '2025-12-02',
|
||||||
|
updatedAt: '2025-12-07',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm10',
|
||||||
|
constructionNumber: '121221',
|
||||||
|
partnerName: 'SK건설',
|
||||||
|
siteName: '광주 북구 현장',
|
||||||
|
constructionPM: '오차장',
|
||||||
|
workTeamLeader: '정반장',
|
||||||
|
worker: '세종대왕',
|
||||||
|
workerCount: 2,
|
||||||
|
constructionStartDate: '2025-12-06',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-06',
|
||||||
|
periodEnd: '2025-12-15',
|
||||||
|
createdAt: '2025-11-28',
|
||||||
|
updatedAt: '2025-12-06',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cm11',
|
||||||
|
constructionNumber: '121222',
|
||||||
|
partnerName: '대한건설',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionPM: '김철수',
|
||||||
|
workTeamLeader: '이반장',
|
||||||
|
worker: '장영실',
|
||||||
|
workerCount: 5,
|
||||||
|
constructionStartDate: '2025-12-05',
|
||||||
|
constructionEndDate: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
periodStart: '2025-12-05',
|
||||||
|
periodEnd: '2025-12-14',
|
||||||
|
createdAt: '2025-11-27',
|
||||||
|
updatedAt: '2025-12-05',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 목록 조회
|
||||||
|
export async function getConstructionManagementList(
|
||||||
|
filter?: ConstructionManagementFilter
|
||||||
|
): Promise<{ success: boolean; data?: ConstructionManagementListResponse; error?: string }> {
|
||||||
|
try {
|
||||||
|
let filtered = [...mockConstructionManagements];
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (filter?.partners && filter.partners.length > 0) {
|
||||||
|
const partnerLabels = filter.partners.map((v) => {
|
||||||
|
const found = [
|
||||||
|
{ value: 'partner1', label: '대한건설' },
|
||||||
|
{ value: 'partner2', label: '삼성시공' },
|
||||||
|
{ value: 'partner3', label: 'LG건설' },
|
||||||
|
{ value: 'partner4', label: '현대건설' },
|
||||||
|
{ value: 'partner5', label: 'SK건설' },
|
||||||
|
].find((p) => p.value === v);
|
||||||
|
return found?.label;
|
||||||
|
}).filter(Boolean);
|
||||||
|
filtered = filtered.filter((c) => partnerLabels.includes(c.partnerName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장명 필터 (다중선택)
|
||||||
|
if (filter?.sites && filter.sites.length > 0) {
|
||||||
|
const siteLabels = filter.sites.map((v) => {
|
||||||
|
const found = [
|
||||||
|
{ value: 'site1', label: '서울 강남 현장' },
|
||||||
|
{ value: 'site2', label: '부산 해운대 현장' },
|
||||||
|
{ value: 'site3', label: '대전 유성 현장' },
|
||||||
|
{ value: 'site4', label: '인천 송도 현장' },
|
||||||
|
{ value: 'site5', label: '광주 북구 현장' },
|
||||||
|
].find((s) => s.value === v);
|
||||||
|
return found?.label;
|
||||||
|
}).filter(Boolean);
|
||||||
|
filtered = filtered.filter((c) => siteLabels.includes(c.siteName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공사PM 필터 (다중선택)
|
||||||
|
if (filter?.constructionPMs && filter.constructionPMs.length > 0) {
|
||||||
|
const pmLabels = filter.constructionPMs.map((v) => {
|
||||||
|
const found = [
|
||||||
|
{ value: 'pm1', label: '김철수' },
|
||||||
|
{ value: 'pm2', label: '박민수' },
|
||||||
|
{ value: 'pm3', label: '정대리' },
|
||||||
|
{ value: 'pm4', label: '윤대리' },
|
||||||
|
{ value: 'pm5', label: '오차장' },
|
||||||
|
].find((p) => p.value === v);
|
||||||
|
return found?.label;
|
||||||
|
}).filter(Boolean);
|
||||||
|
filtered = filtered.filter((c) => pmLabels.includes(c.constructionPM));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업반장 필터 (다중선택)
|
||||||
|
if (filter?.workTeamLeaders && filter.workTeamLeaders.length > 0) {
|
||||||
|
const leaderLabels = filter.workTeamLeaders.map((v) => {
|
||||||
|
const found = [
|
||||||
|
{ value: 'leader1', label: '이반장' },
|
||||||
|
{ value: 'leader2', label: '김반장' },
|
||||||
|
{ value: 'leader3', label: '박반장' },
|
||||||
|
{ value: 'leader4', label: '최반장' },
|
||||||
|
{ value: 'leader5', label: '정반장' },
|
||||||
|
].find((l) => l.value === v);
|
||||||
|
return found?.label;
|
||||||
|
}).filter(Boolean);
|
||||||
|
filtered = filtered.filter((c) => leaderLabels.includes(c.workTeamLeader));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터 (단일선택)
|
||||||
|
if (filter?.status && filter.status !== 'all') {
|
||||||
|
filtered = filtered.filter((c) => c.status === filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (filter?.sortBy) {
|
||||||
|
switch (filter.sortBy) {
|
||||||
|
case 'latest':
|
||||||
|
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'register':
|
||||||
|
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'completionDateDesc':
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
if (!a.constructionEndDate) return 1;
|
||||||
|
if (!b.constructionEndDate) return -1;
|
||||||
|
return new Date(b.constructionEndDate).getTime() - new Date(a.constructionEndDate).getTime();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'partnerNameAsc':
|
||||||
|
filtered.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'partnerNameDesc':
|
||||||
|
filtered.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filter?.page ?? 1;
|
||||||
|
const size = filter?.size ?? 1000;
|
||||||
|
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('getConstructionManagementList error:', error);
|
||||||
|
return { success: false, error: '시공관리 목록 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 통계 조회
|
||||||
|
export async function getConstructionManagementStats(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: ConstructionManagementStats;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const total = mockConstructionManagements.length;
|
||||||
|
const inProgress = mockConstructionManagements.filter((c) => c.status === 'in_progress').length;
|
||||||
|
const completed = mockConstructionManagements.filter((c) => c.status === 'completed').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total,
|
||||||
|
inProgress,
|
||||||
|
completed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getConstructionManagementStats error:', error);
|
||||||
|
return { success: false, error: '시공관리 통계 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 상세 조회
|
||||||
|
export async function getConstructionManagement(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; data?: ConstructionManagement; error?: string }> {
|
||||||
|
try {
|
||||||
|
const item = mockConstructionManagements.find((c) => c.id === id);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, error: '시공 정보를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: item };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getConstructionManagement error:', error);
|
||||||
|
return { success: false, error: '시공 정보 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 삭제
|
||||||
|
export async function deleteConstructionManagement(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Delete construction management:', id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteConstructionManagement error:', error);
|
||||||
|
return { success: false, error: '시공 정보 삭제에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 일괄 삭제
|
||||||
|
export async function deleteConstructionManagements(
|
||||||
|
ids: string[]
|
||||||
|
): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Delete construction managements:', ids);
|
||||||
|
return { success: true, deletedCount: ids.length };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteConstructionManagements error:', error);
|
||||||
|
return { success: false, error: '시공 정보 일괄 삭제에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 시공 상세 페이지 Server Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConstructionManagementDetail,
|
||||||
|
ConstructionDetailFormData,
|
||||||
|
WorkerInfo,
|
||||||
|
WorkProgressInfo,
|
||||||
|
PhotoInfo,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 시공 상세 목업 데이터
|
||||||
|
const mockConstructionDetails: Record<string, ConstructionManagementDetail> = {
|
||||||
|
cm1: {
|
||||||
|
id: 'cm1',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
siteName: '서울 강남 현장',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: null,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
status: 'in_progress',
|
||||||
|
workerInfoList: [
|
||||||
|
{ id: 'w1', workDate: '2026-01-05', workers: ['홍길동', '김영희'] },
|
||||||
|
{ id: 'w2', workDate: '2026-01-06', workers: ['홍길동', '이철수'] },
|
||||||
|
],
|
||||||
|
workProgressList: [
|
||||||
|
{ id: 'wp1', scheduleDate: '2026-01-05 12:12', workName: '공과명' },
|
||||||
|
],
|
||||||
|
orderNumber: '123123',
|
||||||
|
orderId: 'order1',
|
||||||
|
issueCount: 0,
|
||||||
|
workLogContent: '',
|
||||||
|
photos: [],
|
||||||
|
isIssueReported: false,
|
||||||
|
createdAt: '2025-12-10',
|
||||||
|
updatedAt: '2025-12-15',
|
||||||
|
},
|
||||||
|
cm2: {
|
||||||
|
id: 'cm2',
|
||||||
|
constructionNumber: '123124',
|
||||||
|
siteName: '부산 해운대 현장',
|
||||||
|
constructionStartDate: '2025-12-14',
|
||||||
|
constructionEndDate: '2025-12-16',
|
||||||
|
workTeamLeader: '김반장',
|
||||||
|
status: 'completed',
|
||||||
|
workerInfoList: [
|
||||||
|
{ id: 'w3', workDate: '2025-12-14', workers: ['이순신', '강감찬'] },
|
||||||
|
{ id: 'w4', workDate: '2025-12-15', workers: ['이순신'] },
|
||||||
|
{ id: 'w5', workDate: '2025-12-16', workers: ['이순신', '강감찬', '을지문덕'] },
|
||||||
|
],
|
||||||
|
workProgressList: [
|
||||||
|
{ id: 'wp2', scheduleDate: '2025-12-14 09:00', workName: '기초 공사' },
|
||||||
|
{ id: 'wp3', scheduleDate: '2025-12-15 14:00', workName: '배선 작업' },
|
||||||
|
],
|
||||||
|
orderNumber: '123124',
|
||||||
|
orderId: 'order2',
|
||||||
|
issueCount: 2,
|
||||||
|
workLogContent: '금일 기초 공사 및 배선 작업 완료. 특이사항 없음.',
|
||||||
|
photos: [
|
||||||
|
{ id: 'p1', url: '/images/sample1.jpg', name: '현장사진1.jpg', uploadedAt: '2025-12-16' },
|
||||||
|
],
|
||||||
|
isIssueReported: false,
|
||||||
|
createdAt: '2025-12-08',
|
||||||
|
updatedAt: '2025-12-16',
|
||||||
|
},
|
||||||
|
cm3: {
|
||||||
|
id: 'cm3',
|
||||||
|
constructionNumber: '123125',
|
||||||
|
siteName: '대전 유성 현장',
|
||||||
|
constructionStartDate: '2025-12-13',
|
||||||
|
constructionEndDate: null,
|
||||||
|
workTeamLeader: '박반장',
|
||||||
|
status: 'in_progress',
|
||||||
|
workerInfoList: [
|
||||||
|
{ id: 'w6', workDate: '2025-12-13', workers: ['강감찬'] },
|
||||||
|
],
|
||||||
|
workProgressList: [],
|
||||||
|
orderNumber: '123125',
|
||||||
|
orderId: 'order3',
|
||||||
|
issueCount: 1,
|
||||||
|
workLogContent: '',
|
||||||
|
photos: [],
|
||||||
|
isIssueReported: true,
|
||||||
|
createdAt: '2025-12-05',
|
||||||
|
updatedAt: '2025-12-13',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공 상세 조회
|
||||||
|
export async function getConstructionManagementDetail(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; data?: ConstructionManagementDetail; error?: string }> {
|
||||||
|
try {
|
||||||
|
// 목업 데이터에서 찾기
|
||||||
|
let detail = mockConstructionDetails[id];
|
||||||
|
|
||||||
|
// 없으면 리스트에서 찾아서 기본 상세 데이터 생성
|
||||||
|
if (!detail) {
|
||||||
|
const listItem = mockConstructionManagements.find((c) => c.id === id);
|
||||||
|
if (!listItem) {
|
||||||
|
return { success: false, error: '시공 정보를 찾을 수 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 상세 데이터 생성
|
||||||
|
detail = {
|
||||||
|
id: listItem.id,
|
||||||
|
constructionNumber: listItem.constructionNumber,
|
||||||
|
siteName: listItem.siteName,
|
||||||
|
constructionStartDate: listItem.constructionStartDate,
|
||||||
|
constructionEndDate: listItem.constructionEndDate,
|
||||||
|
workTeamLeader: listItem.workTeamLeader,
|
||||||
|
status: listItem.status,
|
||||||
|
workerInfoList: [],
|
||||||
|
workProgressList: [],
|
||||||
|
orderNumber: listItem.constructionNumber,
|
||||||
|
orderId: `order-${listItem.id}`,
|
||||||
|
issueCount: 0,
|
||||||
|
workLogContent: '',
|
||||||
|
photos: [],
|
||||||
|
isIssueReported: false,
|
||||||
|
createdAt: listItem.createdAt,
|
||||||
|
updatedAt: listItem.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: detail };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getConstructionManagementDetail error:', error);
|
||||||
|
return { success: false, error: '시공 상세 조회에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공 상세 수정
|
||||||
|
export async function updateConstructionManagementDetail(
|
||||||
|
id: string,
|
||||||
|
data: Partial<ConstructionDetailFormData>
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Update construction detail:', id, data);
|
||||||
|
// 실제 구현에서는 DB 업데이트
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateConstructionManagementDetail error:', error);
|
||||||
|
return { success: false, error: '시공 상세 수정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공 완료 처리
|
||||||
|
export async function completeConstruction(
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('Complete construction:', id);
|
||||||
|
// 실제 구현에서는 상태를 completed로 변경
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('completeConstruction error:', error);
|
||||||
|
return { success: false, error: '시공 완료 처리에 실패했습니다.' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -95,4 +95,366 @@ export const GANTT_BAR_COLORS = {
|
|||||||
completed: '#9CA3AF', // 회색 - 종료
|
completed: '#9CA3AF', // 회색 - 종료
|
||||||
in_progress: '#3B82F6', // 파란색 - 진행중
|
in_progress: '#3B82F6', // 파란색 - 진행중
|
||||||
urgent: '#991B1B', // 버건디 - 긴급 이슈
|
urgent: '#991B1B', // 버건디 - 긴급 이슈
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 프로젝트 실행관리 상세 페이지 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 단계 타입
|
||||||
|
export type StageType = 'bid' | 'contract' | 'order' | 'construction' | 'payment';
|
||||||
|
|
||||||
|
// 단계 라벨
|
||||||
|
export const STAGE_LABELS: Record<StageType, string> = {
|
||||||
|
bid: '입찰',
|
||||||
|
contract: '계약',
|
||||||
|
order: '발주',
|
||||||
|
construction: '시공',
|
||||||
|
payment: '기성청구',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상세 항목 타입 (하위 목록 없는 경우)
|
||||||
|
export type DetailType =
|
||||||
|
| 'site_briefing' // 현장설명회
|
||||||
|
| 'estimation' // 건적
|
||||||
|
| 'bid_result' // 입찰
|
||||||
|
| 'handover_report' // 인수인계보고서
|
||||||
|
| 'structure_review' // 구조검토
|
||||||
|
| 'completion'; // 종료
|
||||||
|
|
||||||
|
// 상세 항목 라벨 및 날짜 필드명
|
||||||
|
export const DETAIL_CONFIG: Record<DetailType, { label: string; dateLabel: string }> = {
|
||||||
|
site_briefing: { label: '현장설명회', dateLabel: '현장설명회일' },
|
||||||
|
estimation: { label: '건적', dateLabel: '건적완료일' },
|
||||||
|
bid_result: { label: '입찰', dateLabel: '확정일' },
|
||||||
|
handover_report: { label: '인수인계보고서', dateLabel: '공사PM' },
|
||||||
|
structure_review: { label: '구조검토', dateLabel: '구조검토완료일' },
|
||||||
|
completion: { label: '종료', dateLabel: '결선완료일' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계 카드 상태
|
||||||
|
export type StageCardStatus = 'waiting' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
// 단계 카드 상태 라벨
|
||||||
|
export const STAGE_CARD_STATUS_LABELS: Record<StageCardStatus, string> = {
|
||||||
|
waiting: '대기',
|
||||||
|
in_progress: '진행중',
|
||||||
|
completed: '완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단계 데이터
|
||||||
|
export interface Stage {
|
||||||
|
id: string;
|
||||||
|
type: StageType;
|
||||||
|
siteName: string;
|
||||||
|
status: StageCardStatus;
|
||||||
|
date?: string; // 해당 단계 날짜
|
||||||
|
amount?: number; // 금액 (계약금 등)
|
||||||
|
count?: number; // 개소 수
|
||||||
|
pm?: string; // 담당 PM
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 항목 데이터 (하위 목록 없는 경우)
|
||||||
|
export interface StageDetail {
|
||||||
|
id: string;
|
||||||
|
type: DetailType;
|
||||||
|
title: string; // 제목 (현장명 등)
|
||||||
|
date?: string; // 날짜
|
||||||
|
pm?: string; // 담당자 (인수인계보고서용)
|
||||||
|
status?: StageCardStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 상세 (칸반 보드용)
|
||||||
|
export interface ProjectDetail extends Project {
|
||||||
|
stages: Stage[]; // 단계 목록
|
||||||
|
details: StageDetail[]; // 상세 목록
|
||||||
|
detailCategories?: DetailCategory[]; // 상세 카테고리 (시공, 이슈 아코디언)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 종료 폼 데이터
|
||||||
|
export interface ProjectEndFormData {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string; // 현장명 (읽기전용)
|
||||||
|
workDate: string; // 결선작업일 (읽기전용)
|
||||||
|
completionDate: string; // 결선완료일
|
||||||
|
status: 'in_progress' | 'completed'; // 상태
|
||||||
|
memo: string; // 메모
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 종료 상태 옵션
|
||||||
|
export const PROJECT_END_STATUS_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'in_progress', label: '프로젝트 진행' },
|
||||||
|
{ value: 'completed', label: '프로젝트 완료' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 상세 컬럼 아코디언 구조 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 상세 카테고리 타입 (시공, 이슈 등)
|
||||||
|
export type DetailCategoryType = 'construction' | 'issue';
|
||||||
|
|
||||||
|
// 상세 카테고리 라벨
|
||||||
|
export const DETAIL_CATEGORY_LABELS: Record<DetailCategoryType, string> = {
|
||||||
|
construction: '시공',
|
||||||
|
issue: '이슈',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공 상태
|
||||||
|
export type ConstructionStatus = 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
// 시공 상태 라벨
|
||||||
|
export const CONSTRUCTION_STATUS_LABELS: Record<ConstructionStatus, string> = {
|
||||||
|
in_progress: '시공진행',
|
||||||
|
completed: '시공완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이슈 상태
|
||||||
|
export type IssueStatus = 'open' | 'resolved';
|
||||||
|
|
||||||
|
// 이슈 상태 라벨
|
||||||
|
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
|
||||||
|
open: '미해결',
|
||||||
|
resolved: '해결완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공 상세 항목
|
||||||
|
export interface ConstructionItem {
|
||||||
|
id: string;
|
||||||
|
number: string; // 번호 (123123)
|
||||||
|
inputDate: string; // 시공투입일
|
||||||
|
status: ConstructionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 상세 항목
|
||||||
|
export interface IssueItem {
|
||||||
|
id: string;
|
||||||
|
number: string; // 번호
|
||||||
|
title: string; // 이슈 제목
|
||||||
|
status: IssueStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 카테고리 데이터
|
||||||
|
export interface DetailCategory {
|
||||||
|
type: DetailCategoryType;
|
||||||
|
count: number;
|
||||||
|
constructionItems?: ConstructionItem[];
|
||||||
|
issueItems?: IssueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 시공관리 리스트 페이지 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 시공관리 상태
|
||||||
|
export type ConstructionManagementStatus = 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
// 시공관리 상태 라벨
|
||||||
|
export const CONSTRUCTION_MANAGEMENT_STATUS_LABELS: Record<ConstructionManagementStatus, string> = {
|
||||||
|
in_progress: '시공진행',
|
||||||
|
completed: '시공완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공관리 상태 스타일
|
||||||
|
export const CONSTRUCTION_MANAGEMENT_STATUS_STYLES: Record<ConstructionManagementStatus, string> = {
|
||||||
|
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시공관리 리스트 아이템
|
||||||
|
export interface ConstructionManagement {
|
||||||
|
id: string;
|
||||||
|
constructionNumber: string; // 시공번호
|
||||||
|
partnerName: string; // 거래처
|
||||||
|
siteName: string; // 현장명
|
||||||
|
constructionPM: string; // 공사PM
|
||||||
|
workTeamLeader: string; // 작업반장
|
||||||
|
worker: string; // 작업자
|
||||||
|
workerCount: number; // 작업자 인원수
|
||||||
|
constructionStartDate: string; // 시공투입일
|
||||||
|
constructionEndDate: string | null; // 시공완료일
|
||||||
|
status: ConstructionManagementStatus;
|
||||||
|
periodStart: string; // 달력용 시작일
|
||||||
|
periodEnd: string; // 달력용 종료일
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 통계
|
||||||
|
export interface ConstructionManagementStats {
|
||||||
|
total: number;
|
||||||
|
inProgress: number;
|
||||||
|
completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 필터
|
||||||
|
export interface ConstructionManagementFilter {
|
||||||
|
partners?: string[]; // 거래처 (다중선택)
|
||||||
|
sites?: string[]; // 현장명 (다중선택)
|
||||||
|
constructionPMs?: string[]; // 공사PM (다중선택)
|
||||||
|
workTeamLeaders?: string[]; // 작업반장 (다중선택)
|
||||||
|
status?: string; // 상태 (단일선택)
|
||||||
|
sortBy?: string; // 정렬
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 리스트 응답
|
||||||
|
export interface ConstructionManagementListResponse {
|
||||||
|
items: ConstructionManagement[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공관리 상태 옵션
|
||||||
|
export const CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'in_progress', label: '시공중' },
|
||||||
|
{ value: 'completed', label: '완료' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 정렬 옵션
|
||||||
|
export const CONSTRUCTION_MANAGEMENT_SORT_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'register', label: '등록순' },
|
||||||
|
{ value: 'completionDateDesc', label: '시공완료일 최신순' },
|
||||||
|
{ value: 'partnerNameAsc', label: '거래처명 오름차' },
|
||||||
|
{ value: 'partnerNameDesc', label: '거래처명 내림차' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 목업 거래처 목록
|
||||||
|
export const MOCK_CM_PARTNERS: SelectOption[] = [
|
||||||
|
{ value: 'partner1', label: '대한건설' },
|
||||||
|
{ value: 'partner2', label: '삼성시공' },
|
||||||
|
{ value: 'partner3', label: 'LG건설' },
|
||||||
|
{ value: 'partner4', label: '현대건설' },
|
||||||
|
{ value: 'partner5', label: 'SK건설' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 목업 현장 목록
|
||||||
|
export const MOCK_CM_SITES: SelectOption[] = [
|
||||||
|
{ value: 'site1', label: '서울 강남 현장' },
|
||||||
|
{ value: 'site2', label: '부산 해운대 현장' },
|
||||||
|
{ value: 'site3', label: '대전 유성 현장' },
|
||||||
|
{ value: 'site4', label: '인천 송도 현장' },
|
||||||
|
{ value: 'site5', label: '광주 북구 현장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 목업 공사PM 목록
|
||||||
|
export const MOCK_CM_CONSTRUCTION_PM: SelectOption[] = [
|
||||||
|
{ value: 'pm1', label: '김철수' },
|
||||||
|
{ value: 'pm2', label: '박민수' },
|
||||||
|
{ value: 'pm3', label: '정대리' },
|
||||||
|
{ value: 'pm4', label: '윤대리' },
|
||||||
|
{ value: 'pm5', label: '오차장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 목업 작업반장 목록
|
||||||
|
export const MOCK_CM_WORK_TEAM_LEADERS: SelectOption[] = [
|
||||||
|
{ value: 'leader1', label: '이반장' },
|
||||||
|
{ value: 'leader2', label: '김반장' },
|
||||||
|
{ value: 'leader3', label: '박반장' },
|
||||||
|
{ value: 'leader4', label: '최반장' },
|
||||||
|
{ value: 'leader5', label: '정반장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시공관리 달력 색상 (작업반장별)
|
||||||
|
export const getConstructionScheduleColor = (workTeamLeader: string): string => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
'이반장': '#3B82F6', // blue
|
||||||
|
'김반장': '#EF4444', // red
|
||||||
|
'박반장': '#22C55E', // green
|
||||||
|
'최반장': '#F59E0B', // amber
|
||||||
|
'정반장': '#8B5CF6', // purple
|
||||||
|
};
|
||||||
|
return colorMap[workTeamLeader] || '#6B7280'; // 기본 gray
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 시공 상세 페이지 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 작업자 정보
|
||||||
|
export interface WorkerInfo {
|
||||||
|
id: string;
|
||||||
|
workDate: string; // 작업일
|
||||||
|
workers: string[]; // 작업자 목록 (다중선택)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공과 정보
|
||||||
|
export interface WorkProgressInfo {
|
||||||
|
id: string;
|
||||||
|
scheduleDate: string; // 일정 (날짜+시간)
|
||||||
|
workName: string; // 공과명
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진 정보
|
||||||
|
export interface PhotoInfo {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공 상세 데이터
|
||||||
|
export interface ConstructionManagementDetail {
|
||||||
|
id: string;
|
||||||
|
constructionNumber: string; // 시공번호
|
||||||
|
siteName: string; // 현장
|
||||||
|
constructionStartDate: string; // 시공투입일
|
||||||
|
constructionEndDate: string | null; // 시공완료일
|
||||||
|
workTeamLeader: string; // 작업반장
|
||||||
|
status: ConstructionManagementStatus;
|
||||||
|
|
||||||
|
// 작업자 정보 (동적 테이블)
|
||||||
|
workerInfoList: WorkerInfo[];
|
||||||
|
|
||||||
|
// 공과 정보 (동적 테이블)
|
||||||
|
workProgressList: WorkProgressInfo[];
|
||||||
|
|
||||||
|
// 발주서 정보
|
||||||
|
orderNumber: string; // 발주번호
|
||||||
|
orderId: string; // 발주 ID (팝업용)
|
||||||
|
|
||||||
|
// 이슈 정보
|
||||||
|
issueCount: number; // 이슈 건수
|
||||||
|
|
||||||
|
// 작업일지
|
||||||
|
workLogContent: string; // 작업일지 내용
|
||||||
|
|
||||||
|
// 사진
|
||||||
|
photos: PhotoInfo[];
|
||||||
|
|
||||||
|
// 이슈 보고 체크 여부
|
||||||
|
isIssueReported: boolean;
|
||||||
|
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시공 상세 폼 데이터 (수정용)
|
||||||
|
export interface ConstructionDetailFormData {
|
||||||
|
workTeamLeader: string;
|
||||||
|
workerInfoList: WorkerInfo[];
|
||||||
|
workProgressList: WorkProgressInfo[];
|
||||||
|
workLogContent: string;
|
||||||
|
photos: PhotoInfo[];
|
||||||
|
isIssueReported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목업 사원 목록 (작업자 선택용)
|
||||||
|
export const MOCK_EMPLOYEES: SelectOption[] = [
|
||||||
|
{ value: 'emp1', label: '홍길동' },
|
||||||
|
{ value: 'emp2', label: '김영희' },
|
||||||
|
{ value: 'emp3', label: '이철수' },
|
||||||
|
{ value: 'emp4', label: '박민수' },
|
||||||
|
{ value: 'emp5', label: '정대리' },
|
||||||
|
{ value: 'emp6', label: '최과장' },
|
||||||
|
{ value: 'emp7', label: '윤부장' },
|
||||||
|
{ value: 'emp8', label: '오차장' },
|
||||||
|
];
|
||||||
@@ -8,6 +8,7 @@ import type { OrderDetail } from './types';
|
|||||||
import { useOrderDetailForm } from './hooks/useOrderDetailForm';
|
import { useOrderDetailForm } from './hooks/useOrderDetailForm';
|
||||||
import { OrderInfoCard } from './cards/OrderInfoCard';
|
import { OrderInfoCard } from './cards/OrderInfoCard';
|
||||||
import { ContractInfoCard } from './cards/ContractInfoCard';
|
import { ContractInfoCard } from './cards/ContractInfoCard';
|
||||||
|
import { ConstructionDetailCard } from './cards/ConstructionDetailCard';
|
||||||
import { OrderScheduleCard } from './cards/OrderScheduleCard';
|
import { OrderScheduleCard } from './cards/OrderScheduleCard';
|
||||||
import { OrderMemoCard } from './cards/OrderMemoCard';
|
import { OrderMemoCard } from './cards/OrderMemoCard';
|
||||||
import { OrderDetailItemTable } from './tables/OrderDetailItemTable';
|
import { OrderDetailItemTable } from './tables/OrderDetailItemTable';
|
||||||
@@ -159,6 +160,13 @@ export default function OrderDetailForm({
|
|||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 시공 상세 */}
|
||||||
|
<ConstructionDetailCard
|
||||||
|
formData={formData}
|
||||||
|
isViewMode={isViewMode}
|
||||||
|
onFieldChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 발주 스케줄 (달력) */}
|
{/* 발주 스케줄 (달력) */}
|
||||||
<OrderScheduleCard
|
<OrderScheduleCard
|
||||||
events={calendarEvents}
|
events={calendarEvents}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
||||||
@@ -70,9 +70,8 @@ const tableColumns: TableColumn[] = [
|
|||||||
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
|
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
|
||||||
{ key: 'item', label: '품목', className: 'w-[80px]' },
|
{ key: 'item', label: '품목', className: 'w-[80px]' },
|
||||||
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
|
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
|
||||||
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
|
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' }, { key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
|
||||||
{ key: 'plannedDeliveryDate', label: '계획납품일', className: 'w-[90px]' },
|
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
|
||||||
{ key: 'actualDeliveryDate', label: '실제납품일', className: 'w-[90px]' },
|
|
||||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||||
];
|
];
|
||||||
@@ -568,41 +567,41 @@ export default function OrderManagementListClient({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stats 카드 데이터
|
// Stats 카드 데이터 - 기획서에 없어서 주석 처리
|
||||||
const statsCardsData: StatCard[] = [
|
// const statsCardsData: StatCard[] = [
|
||||||
{
|
// {
|
||||||
label: '전체 발주',
|
// label: '전체 발주',
|
||||||
value: stats?.total ?? 0,
|
// value: stats?.total ?? 0,
|
||||||
icon: Package,
|
// icon: Package,
|
||||||
iconColor: 'text-blue-600',
|
// iconColor: 'text-blue-600',
|
||||||
onClick: () => setActiveStatTab('all'),
|
// onClick: () => setActiveStatTab('all'),
|
||||||
isActive: activeStatTab === 'all',
|
// isActive: activeStatTab === 'all',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: '발주대기',
|
// label: '발주대기',
|
||||||
value: stats?.waiting ?? 0,
|
// value: stats?.waiting ?? 0,
|
||||||
icon: Clock,
|
// icon: Clock,
|
||||||
iconColor: 'text-yellow-600',
|
// iconColor: 'text-yellow-600',
|
||||||
onClick: () => setActiveStatTab('waiting'),
|
// onClick: () => setActiveStatTab('waiting'),
|
||||||
isActive: activeStatTab === 'waiting',
|
// isActive: activeStatTab === 'waiting',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: '발주완료',
|
// label: '발주완료',
|
||||||
value: stats?.orderComplete ?? 0,
|
// value: stats?.orderComplete ?? 0,
|
||||||
icon: AlertCircle,
|
// icon: AlertCircle,
|
||||||
iconColor: 'text-blue-600',
|
// iconColor: 'text-blue-600',
|
||||||
onClick: () => setActiveStatTab('order_complete'),
|
// onClick: () => setActiveStatTab('order_complete'),
|
||||||
isActive: activeStatTab === 'order_complete',
|
// isActive: activeStatTab === 'order_complete',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: '납품완료',
|
// label: '납품완료',
|
||||||
value: stats?.deliveryComplete ?? 0,
|
// value: stats?.deliveryComplete ?? 0,
|
||||||
icon: CheckCircle,
|
// icon: CheckCircle,
|
||||||
iconColor: 'text-green-600',
|
// iconColor: 'text-green-600',
|
||||||
onClick: () => setActiveStatTab('delivery_complete'),
|
// onClick: () => setActiveStatTab('delivery_complete'),
|
||||||
isActive: activeStatTab === 'delivery_complete',
|
// isActive: activeStatTab === 'delivery_complete',
|
||||||
},
|
// },
|
||||||
];
|
// ];
|
||||||
|
|
||||||
// 필터 옵션들
|
// 필터 옵션들
|
||||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||||
@@ -611,8 +610,82 @@ export default function OrderManagementListClient({
|
|||||||
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
|
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
|
||||||
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
|
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
|
||||||
|
|
||||||
// 테이블 헤더 액션 (기획서 요구사항)
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
// 거래처, 현장명, 공사PM, 발주담당자, 발주처, 작업반장, 구분, 상태, 최신순
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||||
|
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
|
||||||
|
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
|
||||||
|
{ key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions },
|
||||||
|
{ key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions },
|
||||||
|
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
|
||||||
|
{ key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions },
|
||||||
|
{ key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||||
|
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' },
|
||||||
|
], [partnerOptions, siteOptions, constructionPMOptions, orderManagerOptions, orderCompanyOptions, workTeamOptions, orderTypeOptions]);
|
||||||
|
|
||||||
|
// filterValues 객체
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
partners: partnerFilters,
|
||||||
|
sites: siteNameFilters,
|
||||||
|
constructionPMs: constructionPMFilters,
|
||||||
|
orderManagers: orderManagerFilters,
|
||||||
|
orderCompanies: orderCompanyFilters,
|
||||||
|
workTeamLeaders: workTeamFilters,
|
||||||
|
orderTypes: orderTypeFilters,
|
||||||
|
status: statusFilter,
|
||||||
|
sortBy: sortBy,
|
||||||
|
}), [partnerFilters, siteNameFilters, constructionPMFilters, orderManagerFilters, orderCompanyFilters, workTeamFilters, orderTypeFilters, statusFilter, sortBy]);
|
||||||
|
|
||||||
|
// 필터 변경 핸들러
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'partners':
|
||||||
|
setPartnerFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'sites':
|
||||||
|
setSiteNameFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'constructionPMs':
|
||||||
|
setConstructionPMFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'orderManagers':
|
||||||
|
setOrderManagerFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'orderCompanies':
|
||||||
|
setOrderCompanyFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'workTeamLeaders':
|
||||||
|
setWorkTeamFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'orderTypes':
|
||||||
|
setOrderTypeFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
setStatusFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'sortBy':
|
||||||
|
setSortBy(value as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터 초기화 핸들러
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setPartnerFilters([]);
|
||||||
|
setSiteNameFilters([]);
|
||||||
|
setConstructionPMFilters([]);
|
||||||
|
setOrderManagerFilters([]);
|
||||||
|
setOrderCompanyFilters([]);
|
||||||
|
setWorkTeamFilters([]);
|
||||||
|
setOrderTypeFilters([]);
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSortBy('latest');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 헤더 추가 액션 (총건 표시 + 달력 날짜 필터 해제)
|
||||||
|
// 필터는 filterConfig로 자동 생성됨
|
||||||
const tableHeaderActions = (
|
const tableHeaderActions = (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{/* 총건 표시 */}
|
{/* 총건 표시 */}
|
||||||
@@ -625,104 +698,6 @@ export default function OrderManagementListClient({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 1. 거래처 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={partnerOptions}
|
|
||||||
value={partnerFilters}
|
|
||||||
onChange={setPartnerFilters}
|
|
||||||
placeholder="거래처"
|
|
||||||
searchPlaceholder="거래처 검색..."
|
|
||||||
className="w-[120px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 2. 현장명 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={siteOptions}
|
|
||||||
value={siteNameFilters}
|
|
||||||
onChange={setSiteNameFilters}
|
|
||||||
placeholder="현장명"
|
|
||||||
searchPlaceholder="현장명 검색..."
|
|
||||||
className="w-[140px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 3. 공사PM 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={constructionPMOptions}
|
|
||||||
value={constructionPMFilters}
|
|
||||||
onChange={setConstructionPMFilters}
|
|
||||||
placeholder="공사PM"
|
|
||||||
searchPlaceholder="공사PM 검색..."
|
|
||||||
className="w-[120px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 4. 발주담당자 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={orderManagerOptions}
|
|
||||||
value={orderManagerFilters}
|
|
||||||
onChange={setOrderManagerFilters}
|
|
||||||
placeholder="발주담당자"
|
|
||||||
searchPlaceholder="발주담당자 검색..."
|
|
||||||
className="w-[120px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 5. 발주처 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={orderCompanyOptions}
|
|
||||||
value={orderCompanyFilters}
|
|
||||||
onChange={setOrderCompanyFilters}
|
|
||||||
placeholder="발주처"
|
|
||||||
searchPlaceholder="발주처 검색..."
|
|
||||||
className="w-[100px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 6. 작업반장 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={workTeamOptions}
|
|
||||||
value={workTeamFilters}
|
|
||||||
onChange={setWorkTeamFilters}
|
|
||||||
placeholder="작업반장"
|
|
||||||
searchPlaceholder="작업반장 검색..."
|
|
||||||
className="w-[110px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 7. 구분 필터 (다중선택) */}
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={orderTypeOptions}
|
|
||||||
value={orderTypeFilters}
|
|
||||||
onChange={setOrderTypeFilters}
|
|
||||||
placeholder="구분"
|
|
||||||
searchPlaceholder="구분 검색..."
|
|
||||||
className="w-[100px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 8. 상태 필터 (단일선택) */}
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[100px]">
|
|
||||||
<SelectValue placeholder="상태" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ORDER_STATUS_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 9. 최신순 필터 (단일선택) */}
|
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
|
||||||
<SelectTrigger className="w-[100px]">
|
|
||||||
<SelectValue placeholder="정렬" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ORDER_SORT_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 달력 날짜 필터 초기화 */}
|
{/* 달력 날짜 필터 초기화 */}
|
||||||
{selectedCalendarDate && (
|
{selectedCalendarDate && (
|
||||||
<Button
|
<Button
|
||||||
@@ -767,7 +742,13 @@ export default function OrderManagementListClient({
|
|||||||
description="발주 스케줄 및 목록을 관리합니다"
|
description="발주 스케줄 및 목록을 관리합니다"
|
||||||
icon={Package}
|
icon={Package}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
stats={statsCardsData}
|
// stats={statsCardsData} // 기획서에 없어서 주석 처리
|
||||||
|
// 통합 필터 시스템 - PC는 인라인, 모바일은 바텀시트 자동 분기
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="발주 필터"
|
||||||
tableHeaderActions={tableHeaderActions}
|
tableHeaderActions={tableHeaderActions}
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { OrderDetailFormData } from '../types';
|
||||||
|
import { MOCK_WORK_TEAM_LEADERS } from '../types';
|
||||||
|
|
||||||
|
interface ConstructionDetailCardProps {
|
||||||
|
formData: OrderDetailFormData;
|
||||||
|
isViewMode: boolean;
|
||||||
|
onFieldChange: (field: keyof OrderDetailFormData, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConstructionDetailCard({
|
||||||
|
formData,
|
||||||
|
isViewMode,
|
||||||
|
onFieldChange,
|
||||||
|
}: ConstructionDetailCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">시공 상세</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 작업반장 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>작업반장</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.workTeamLeader}
|
||||||
|
onValueChange={(value) => onFieldChange('workTeamLeader', value)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_WORK_TEAM_LEADERS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시공투입일 ~ 시공완료일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
시공투입일<span className="text-destructive ml-1">*</span>
|
||||||
|
<span className="mx-2">~</span>
|
||||||
|
시공완료일
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.constructionStartDate}
|
||||||
|
onChange={(e) => onFieldChange('constructionStartDate', e.target.value)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
required
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.constructionEndDate}
|
||||||
|
onChange={(e) => onFieldChange('constructionEndDate', e.target.value)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export function ContractInfoCard({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* 거래처명 */}
|
{/* 거래처명 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>거래처명</Label>
|
<Label>거래처명<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.partnerId}
|
value={formData.partnerId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -67,7 +67,7 @@ export function ContractInfoCard({
|
|||||||
|
|
||||||
{/* 현장명 */}
|
{/* 현장명 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>현장명</Label>
|
<Label>현장명<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.siteName}
|
value={formData.siteName}
|
||||||
onChange={(e) => onFieldChange('siteName', e.target.value)}
|
onChange={(e) => onFieldChange('siteName', e.target.value)}
|
||||||
@@ -77,7 +77,7 @@ export function ContractInfoCard({
|
|||||||
|
|
||||||
{/* 계약번호 */}
|
{/* 계약번호 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>계약번호</Label>
|
<Label>계약번호<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.contractNumber}
|
value={formData.contractNumber}
|
||||||
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
|
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
|
||||||
@@ -87,7 +87,7 @@ export function ContractInfoCard({
|
|||||||
|
|
||||||
{/* 공사PM */}
|
{/* 공사PM */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>공사PM</Label>
|
<Label>공사PM<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.constructionPMId}
|
value={formData.constructionPMId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -114,7 +114,7 @@ export function ContractInfoCard({
|
|||||||
|
|
||||||
{/* 공사담당자 */}
|
{/* 공사담당자 */}
|
||||||
<div className="space-y-2 md:col-span-2 lg:col-span-4">
|
<div className="space-y-2 md:col-span-2 lg:col-span-4">
|
||||||
<Label>공사담당자</Label>
|
<Label>공사담당자<span className="text-destructive ml-1">*</span></Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formData.constructionManagers.map((manager, index) => (
|
{formData.constructionManagers.map((manager, index) => (
|
||||||
<div key={index} className="flex items-center gap-1">
|
<div key={index} className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* 발주번호 */}
|
{/* 발주번호 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>발주번호</Label>
|
<Label>발주번호<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.orderNumber}
|
value={formData.orderNumber}
|
||||||
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
|
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
|
||||||
@@ -44,7 +44,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
|
|
||||||
{/* 발주일 (발주처) */}
|
{/* 발주일 (발주처) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>발주일</Label>
|
<Label>발주일<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.orderCompanyId}
|
value={formData.orderCompanyId}
|
||||||
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
|
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
|
||||||
@@ -65,7 +65,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
|
|
||||||
{/* 구분 */}
|
{/* 구분 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>구분</Label>
|
<Label>구분<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.orderType}
|
value={formData.orderType}
|
||||||
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
|
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
|
||||||
@@ -86,7 +86,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
|
|
||||||
{/* 상태 */}
|
{/* 상태 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>상태</Label>
|
<Label>상태<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
|
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
|
||||||
@@ -107,7 +107,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
|
|
||||||
{/* 발주담당자 */}
|
{/* 발주담당자 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>발주담당자</Label>
|
<Label>발주담당자<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.orderManager}
|
value={formData.orderManager}
|
||||||
onValueChange={(value) => onFieldChange('orderManager', value)}
|
onValueChange={(value) => onFieldChange('orderManager', value)}
|
||||||
@@ -128,7 +128,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
|||||||
|
|
||||||
{/* 화물도착지 */}
|
{/* 화물도착지 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>화물도착지</Label>
|
<Label>화물도착지<span className="text-destructive ml-1">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.deliveryAddress}
|
value={formData.deliveryAddress}
|
||||||
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}
|
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function OrderDocumentModal({
|
|||||||
{/* 기본 정보 테이블 */}
|
{/* 기본 정보 테이블 */}
|
||||||
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
|
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* 출고일 / 작업팀 */}
|
{/* 출고일 / 작업반장 */}
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
|
||||||
출고일
|
출고일
|
||||||
@@ -133,32 +133,32 @@ export function OrderDocumentModal({
|
|||||||
<td className="border border-gray-300 px-4 py-3">
|
<td className="border border-gray-300 px-4 py-3">
|
||||||
{formatDate(order.plannedDeliveryDate)}
|
{formatDate(order.plannedDeliveryDate)}
|
||||||
</td>
|
</td>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-32 font-medium">
|
||||||
작업팀
|
작업반장
|
||||||
</th>
|
</th>
|
||||||
<td className="border border-gray-300 px-4 py-3">
|
<td className="border border-gray-300 px-4 py-3">
|
||||||
{order.workTeamLeader || '-'}
|
{order.workTeamLeader || '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* 현장명 / 연락처 */}
|
{/* 현장명 / 작업반장 연락처 */}
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||||
현장명
|
현장명
|
||||||
</th>
|
</th>
|
||||||
<td className="border border-gray-300 px-4 py-3">{order.siteName || '-'}</td>
|
<td className="border border-gray-300 px-4 py-3">{order.siteName || '-'}</td>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||||
연락처
|
작업반장 연락처
|
||||||
</th>
|
</th>
|
||||||
<td className="border border-gray-300 px-4 py-3">-</td>
|
<td className="border border-gray-300 px-4 py-3">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* 화물 도착지 / 발주담당자 */}
|
{/* 화물 도착지 / 발주담당자 */}
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
<th rowSpan={2} className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium align-middle">
|
||||||
화물 도착지
|
화물 도착지
|
||||||
</th>
|
</th>
|
||||||
<td className="border border-gray-300 px-4 py-3">{order.deliveryAddress || '-'}</td>
|
<td rowSpan={2} className="border border-gray-300 px-4 py-3 align-middle">{order.deliveryAddress || '-'}</td>
|
||||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||||
발주담당자
|
발주담당자
|
||||||
</th>
|
</th>
|
||||||
@@ -166,6 +166,14 @@ export function OrderDocumentModal({
|
|||||||
{order.orderManager || '-'}
|
{order.orderManager || '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
{/* 발주담당자 연락처 */}
|
||||||
|
<tr>
|
||||||
|
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||||
|
발주담당자 연락처
|
||||||
|
</th>
|
||||||
|
<td className="border border-gray-300 px-4 py-3">-</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ export function OrderDetailItemTable({
|
|||||||
<TableHead className="w-[100px]">비고</TableHead>
|
<TableHead className="w-[100px]">비고</TableHead>
|
||||||
<TableHead className="w-[60px] text-center">이미지</TableHead>
|
<TableHead className="w-[60px] text-center">이미지</TableHead>
|
||||||
<TableHead className="w-[110px]">발주일</TableHead>
|
<TableHead className="w-[110px]">발주일</TableHead>
|
||||||
<TableHead className="w-[110px]">계획납품일</TableHead>
|
<TableHead className="w-[110px]">계획인수일</TableHead>
|
||||||
<TableHead className="w-[110px]">실제납품일</TableHead>
|
<TableHead className="w-[110px]">실제인수일</TableHead>
|
||||||
<TableHead className="w-[100px]">상태</TableHead>
|
<TableHead className="w-[100px]">상태</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ export interface Order {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
/** 발주일 */
|
/** 발주일 */
|
||||||
orderDate: string;
|
orderDate: string;
|
||||||
/** 계획납품일 */
|
/** 계획인수일 */
|
||||||
plannedDeliveryDate: string;
|
plannedDeliveryDate: string;
|
||||||
/** 실제 납품일 */
|
/** 실제 인수일 */
|
||||||
actualDeliveryDate: string | null;
|
actualDeliveryDate: string | null;
|
||||||
/** 상태 */
|
/** 상태 */
|
||||||
status: OrderStatus;
|
status: OrderStatus;
|
||||||
@@ -74,9 +74,9 @@ export interface OrderStats {
|
|||||||
waiting: number;
|
waiting: number;
|
||||||
/** 발주완료 */
|
/** 발주완료 */
|
||||||
orderComplete: number;
|
orderComplete: number;
|
||||||
/** 납품예정 */
|
/** 인수예정 */
|
||||||
deliveryScheduled: number;
|
deliveryScheduled: number;
|
||||||
/** 납품완료 */
|
/** 인수완료 */
|
||||||
deliveryComplete: number;
|
deliveryComplete: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ export const ORDER_STATUS_OPTIONS = [
|
|||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '전체' },
|
||||||
{ value: 'waiting', label: '발주대기' },
|
{ value: 'waiting', label: '발주대기' },
|
||||||
{ value: 'order_complete', label: '발주완료' },
|
{ value: 'order_complete', label: '발주완료' },
|
||||||
{ value: 'delivery_scheduled', label: '납품예정' },
|
{ value: 'delivery_scheduled', label: '인수예정' },
|
||||||
{ value: 'delivery_complete', label: '납품완료' },
|
{ value: 'delivery_complete', label: '인수완료' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,8 +97,8 @@ export const ORDER_STATUS_OPTIONS = [
|
|||||||
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||||
waiting: '발주대기',
|
waiting: '발주대기',
|
||||||
order_complete: '발주완료',
|
order_complete: '발주완료',
|
||||||
delivery_scheduled: '납품예정',
|
delivery_scheduled: '인수예정',
|
||||||
delivery_complete: '납품완료',
|
delivery_complete: '인수완료',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,8 +210,8 @@ export const ORDER_SORT_OPTIONS = [
|
|||||||
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
|
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
|
||||||
{ value: 'siteNameAsc', label: '현장명 ↑' },
|
{ value: 'siteNameAsc', label: '현장명 ↑' },
|
||||||
{ value: 'siteNameDesc', label: '현장명 ↓' },
|
{ value: 'siteNameDesc', label: '현장명 ↓' },
|
||||||
{ value: 'deliveryDateAsc', label: '납품일 ↑' },
|
{ value: 'deliveryDateAsc', label: '인수일 ↑' },
|
||||||
{ value: 'deliveryDateDesc', label: '납품일 ↓' },
|
{ value: 'deliveryDateDesc', label: '인수일 ↓' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,9 +322,9 @@ export interface OrderDetailItem {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
/** 발주일 */
|
/** 발주일 */
|
||||||
orderDate: string;
|
orderDate: string;
|
||||||
/** 계획 납품일 */
|
/** 계획 인수일 */
|
||||||
plannedDeliveryDate: string;
|
plannedDeliveryDate: string;
|
||||||
/** 실제 납품일 */
|
/** 실제 인수일 */
|
||||||
actualDeliveryDate: string;
|
actualDeliveryDate: string;
|
||||||
/** 상태 */
|
/** 상태 */
|
||||||
status: OrderStatus;
|
status: OrderStatus;
|
||||||
@@ -412,6 +412,12 @@ export interface OrderDetailFormData {
|
|||||||
constructionPM: string;
|
constructionPM: string;
|
||||||
/** 공사담당자 목록 */
|
/** 공사담당자 목록 */
|
||||||
constructionManagers: string[];
|
constructionManagers: string[];
|
||||||
|
/** 작업반장 */
|
||||||
|
workTeamLeader: string;
|
||||||
|
/** 시공투입일 (필수) */
|
||||||
|
constructionStartDate: string;
|
||||||
|
/** 시공완료일 */
|
||||||
|
constructionEndDate: string;
|
||||||
/** 발주 상세 카테고리 목록 */
|
/** 발주 상세 카테고리 목록 */
|
||||||
orderCategories: OrderDetailCategory[];
|
orderCategories: OrderDetailCategory[];
|
||||||
/** 비고 */
|
/** 비고 */
|
||||||
@@ -518,6 +524,9 @@ export function getEmptyOrderDetailFormData(): OrderDetailFormData {
|
|||||||
constructionPMId: '',
|
constructionPMId: '',
|
||||||
constructionPM: '',
|
constructionPM: '',
|
||||||
constructionManagers: [],
|
constructionManagers: [],
|
||||||
|
workTeamLeader: '',
|
||||||
|
constructionStartDate: '',
|
||||||
|
constructionEndDate: '',
|
||||||
orderCategories: [],
|
orderCategories: [],
|
||||||
memo: '',
|
memo: '',
|
||||||
periodStart: '',
|
periodStart: '',
|
||||||
@@ -563,6 +572,9 @@ export function orderDetailToFormData(detail: OrderDetail): OrderDetailFormData
|
|||||||
constructionPMId: detail.constructionPMId,
|
constructionPMId: detail.constructionPMId,
|
||||||
constructionPM: detail.constructionPM,
|
constructionPM: detail.constructionPM,
|
||||||
constructionManagers: detail.constructionManagers,
|
constructionManagers: detail.constructionManagers,
|
||||||
|
workTeamLeader: detail.workTeamLeader,
|
||||||
|
constructionStartDate: detail.constructionStartDate,
|
||||||
|
constructionEndDate: '', // Order 인터페이스에는 없으므로 빈 값
|
||||||
orderCategories: Array.from(categoryMap.values()),
|
orderCategories: Array.from(categoryMap.values()),
|
||||||
memo: detail.memo,
|
memo: detail.memo,
|
||||||
periodStart: detail.periodStart,
|
periodStart: detail.periodStart,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { IntegratedListTemplateV2, TabOption, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
import { IntegratedListTemplateV2, TabOption, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
@@ -375,6 +375,54 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
|||||||
[handleRowClick]
|
[handleRowClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'badDebt',
|
||||||
|
label: '악성채권',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ value: 'badDebt', label: '악성채권' },
|
||||||
|
{ value: 'normal', label: '정상' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortBy',
|
||||||
|
label: '정렬',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'nameAsc', label: '이름 오름차순' },
|
||||||
|
{ value: 'nameDesc', label: '이름 내림차순' },
|
||||||
|
],
|
||||||
|
allOptionLabel: '최신순',
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
badDebt: badDebtFilter,
|
||||||
|
sortBy: sortBy,
|
||||||
|
}), [badDebtFilter, sortBy]);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'badDebt':
|
||||||
|
setBadDebtFilter(value as 'all' | 'badDebt' | 'normal');
|
||||||
|
break;
|
||||||
|
case 'sortBy':
|
||||||
|
setSortBy(value as 'latest' | 'oldest' | 'nameAsc' | 'nameDesc');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setBadDebtFilter('all');
|
||||||
|
setSortBy('latest');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 헤더 액션 (등록 버튼만)
|
// 헤더 액션 (등록 버튼만)
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<div className="flex items-center justify-end w-full">
|
<div className="flex items-center justify-end w-full">
|
||||||
@@ -445,6 +493,11 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
tableHeaderActions={tableHeaderActions}
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="거래처 필터"
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { DollarSign, List } from 'lucide-react';
|
import { DollarSign, List } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,8 +51,6 @@ interface FormData {
|
|||||||
unit: string;
|
unit: string;
|
||||||
division: string;
|
division: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
purchasePrice: number;
|
|
||||||
marginRate: number;
|
|
||||||
sellingPrice: number;
|
sellingPrice: number;
|
||||||
status: PricingStatus;
|
status: PricingStatus;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -66,8 +64,6 @@ const initialFormData: FormData = {
|
|||||||
unit: '',
|
unit: '',
|
||||||
division: '',
|
division: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
purchasePrice: 0,
|
|
||||||
marginRate: 0,
|
|
||||||
sellingPrice: 0,
|
sellingPrice: 0,
|
||||||
status: 'in_use',
|
status: 'in_use',
|
||||||
note: '',
|
note: '',
|
||||||
@@ -109,8 +105,6 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
unit: result.data.unit,
|
unit: result.data.unit,
|
||||||
division: result.data.division,
|
division: result.data.division,
|
||||||
vendor: result.data.vendor,
|
vendor: result.data.vendor,
|
||||||
purchasePrice: result.data.purchasePrice,
|
|
||||||
marginRate: result.data.marginRate,
|
|
||||||
sellingPrice: result.data.sellingPrice,
|
sellingPrice: result.data.sellingPrice,
|
||||||
status: result.data.status,
|
status: result.data.status,
|
||||||
note: '',
|
note: '',
|
||||||
@@ -130,29 +124,12 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
loadData();
|
loadData();
|
||||||
}, [id, mode, isViewMode, isEditMode, router]);
|
}, [id, mode, isViewMode, isEditMode, router]);
|
||||||
|
|
||||||
// 판매단가 자동 계산: 매입단가 * (1 + 마진율/100)
|
// 판매단가 변경
|
||||||
const calculatedSellingPrice = useMemo(() => {
|
const handleSellingPriceChange = useCallback((value: string) => {
|
||||||
const price = formData.purchasePrice * (1 + formData.marginRate / 100);
|
|
||||||
return Math.round(price);
|
|
||||||
}, [formData.purchasePrice, formData.marginRate]);
|
|
||||||
|
|
||||||
// 매입단가 변경
|
|
||||||
const handlePurchasePriceChange = useCallback((value: string) => {
|
|
||||||
const numValue = parseFloat(value) || 0;
|
const numValue = parseFloat(value) || 0;
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
purchasePrice: numValue,
|
sellingPrice: numValue,
|
||||||
sellingPrice: Math.round(numValue * (1 + prev.marginRate / 100)),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 마진율 변경
|
|
||||||
const handleMarginRateChange = useCallback((value: string) => {
|
|
||||||
const numValue = parseFloat(value) || 0;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
marginRate: numValue,
|
|
||||||
sellingPrice: Math.round(prev.purchasePrice * (1 + numValue / 100)),
|
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -185,9 +162,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
unit: formData.unit,
|
unit: formData.unit,
|
||||||
division: formData.division,
|
division: formData.division,
|
||||||
vendor: formData.vendor,
|
vendor: formData.vendor,
|
||||||
purchasePrice: formData.purchasePrice,
|
sellingPrice: formData.sellingPrice,
|
||||||
marginRate: formData.marginRate,
|
|
||||||
sellingPrice: calculatedSellingPrice,
|
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,9 +175,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
} else if (isEditMode && id) {
|
} else if (isEditMode && id) {
|
||||||
const result = await updatePricing(id, {
|
const result = await updatePricing(id, {
|
||||||
vendor: formData.vendor,
|
vendor: formData.vendor,
|
||||||
purchasePrice: formData.purchasePrice,
|
sellingPrice: formData.sellingPrice,
|
||||||
marginRate: formData.marginRate,
|
|
||||||
sellingPrice: calculatedSellingPrice,
|
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +191,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isCreateMode, isEditMode, id, formData, calculatedSellingPrice, router]);
|
}, [isCreateMode, isEditMode, id, formData, router]);
|
||||||
|
|
||||||
// 삭제
|
// 삭제
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
@@ -379,7 +352,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 거래처 / 매입단가 */}
|
{/* 거래처 / 판매단가 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>거래처</Label>
|
<Label>거래처</Label>
|
||||||
@@ -400,48 +373,17 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>매입단가</Label>
|
|
||||||
{isViewMode ? (
|
|
||||||
<Input value={formatNumber(formData.purchasePrice)} disabled />
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.purchasePrice}
|
|
||||||
onChange={(e) => handlePurchasePriceChange(e.target.value)}
|
|
||||||
placeholder="매입단가 입력"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 마진율 / 판매단가 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>마진율 (%)</Label>
|
|
||||||
{isViewMode ? (
|
|
||||||
<Input value={`${formData.marginRate}%`} disabled />
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={formData.marginRate}
|
|
||||||
onChange={(e) => handleMarginRateChange(e.target.value)}
|
|
||||||
placeholder="마진율 입력"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>판매단가</Label>
|
<Label>판매단가</Label>
|
||||||
<Input
|
{isViewMode ? (
|
||||||
value={formatNumber(isViewMode ? formData.sellingPrice : calculatedSellingPrice)}
|
<Input value={formatNumber(formData.sellingPrice)} disabled />
|
||||||
disabled
|
) : (
|
||||||
className="bg-muted"
|
<Input
|
||||||
/>
|
type="number"
|
||||||
{!isViewMode && (
|
value={formData.sellingPrice}
|
||||||
<p className="text-xs text-muted-foreground">
|
onChange={(e) => handleSellingPriceChange(e.target.value)}
|
||||||
매입단가 × (1 + 마진율) = 자동 계산
|
placeholder="판매단가 입력"
|
||||||
</p>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -325,8 +325,6 @@ export default function PricingListClient({
|
|||||||
<TableHead className="w-[60px]">단위</TableHead>
|
<TableHead className="w-[60px]">단위</TableHead>
|
||||||
<TableHead className="w-[60px]">구분</TableHead>
|
<TableHead className="w-[60px]">구분</TableHead>
|
||||||
<TableHead className="w-[120px]">거래처</TableHead>
|
<TableHead className="w-[120px]">거래처</TableHead>
|
||||||
<TableHead className="w-[100px] text-right">매입단가</TableHead>
|
|
||||||
<TableHead className="w-[70px] text-right">마진율</TableHead>
|
|
||||||
<TableHead className="w-[100px] text-right">판매단가</TableHead>
|
<TableHead className="w-[100px] text-right">판매단가</TableHead>
|
||||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||||
{selectedItems.size > 0 && (
|
{selectedItems.size > 0 && (
|
||||||
@@ -368,8 +366,6 @@ export default function PricingListClient({
|
|||||||
<TableCell>{pricing.unit}</TableCell>
|
<TableCell>{pricing.unit}</TableCell>
|
||||||
<TableCell>{pricing.division}</TableCell>
|
<TableCell>{pricing.division}</TableCell>
|
||||||
<TableCell>{pricing.vendor}</TableCell>
|
<TableCell>{pricing.vendor}</TableCell>
|
||||||
<TableCell className="text-right">{formatNumber(pricing.purchasePrice)}</TableCell>
|
|
||||||
<TableCell className="text-right">{pricing.marginRate}%</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(pricing.sellingPrice)}</TableCell>
|
<TableCell className="text-right">{formatNumber(pricing.sellingPrice)}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>
|
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FileText, List, Eye, Edit } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import type { ProgressBillingDetail } from './types';
|
||||||
|
import { useProgressBillingDetailForm } from './hooks/useProgressBillingDetailForm';
|
||||||
|
import { ProgressBillingInfoCard } from './cards/ProgressBillingInfoCard';
|
||||||
|
import { ContractInfoCard } from './cards/ContractInfoCard';
|
||||||
|
import { ProgressBillingItemTable } from './tables/ProgressBillingItemTable';
|
||||||
|
import { PhotoTable } from './tables/PhotoTable';
|
||||||
|
import { DirectConstructionModal } from './modals/DirectConstructionModal';
|
||||||
|
import { IndirectConstructionModal } from './modals/IndirectConstructionModal';
|
||||||
|
import { PhotoDocumentModal } from './modals/PhotoDocumentModal';
|
||||||
|
|
||||||
|
interface ProgressBillingDetailFormProps {
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
billingId: string;
|
||||||
|
initialData?: ProgressBillingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBillingDetailForm({
|
||||||
|
mode,
|
||||||
|
billingId,
|
||||||
|
initialData,
|
||||||
|
}: ProgressBillingDetailFormProps) {
|
||||||
|
const {
|
||||||
|
// Mode flags
|
||||||
|
isViewMode,
|
||||||
|
isEditMode,
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
formData,
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
showDeleteDialog,
|
||||||
|
setShowDeleteDialog,
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
showDirectConstructionModal,
|
||||||
|
setShowDirectConstructionModal,
|
||||||
|
showIndirectConstructionModal,
|
||||||
|
setShowIndirectConstructionModal,
|
||||||
|
showPhotoDocumentModal,
|
||||||
|
setShowPhotoDocumentModal,
|
||||||
|
|
||||||
|
// Selection states
|
||||||
|
selectedBillingItems,
|
||||||
|
selectedPhotoItems,
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
handleBack,
|
||||||
|
handleEdit,
|
||||||
|
handleCancel,
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
handleFieldChange,
|
||||||
|
|
||||||
|
// CRUD handlers
|
||||||
|
handleSave,
|
||||||
|
handleConfirmSave,
|
||||||
|
handleDelete,
|
||||||
|
handleConfirmDelete,
|
||||||
|
|
||||||
|
// Billing item handlers
|
||||||
|
handleBillingItemChange,
|
||||||
|
handleToggleBillingItemSelection,
|
||||||
|
handleToggleSelectAllBillingItems,
|
||||||
|
handleApplySelectedBillingItems,
|
||||||
|
|
||||||
|
// Photo item handlers
|
||||||
|
handleTogglePhotoItemSelection,
|
||||||
|
handleToggleSelectAllPhotoItems,
|
||||||
|
handleApplySelectedPhotoItems,
|
||||||
|
handlePhotoSelect,
|
||||||
|
|
||||||
|
// Modal handlers
|
||||||
|
handleViewDirectConstruction,
|
||||||
|
handleViewIndirectConstruction,
|
||||||
|
handleViewPhotoDocument,
|
||||||
|
} = useProgressBillingDetailForm({ mode, billingId, initialData });
|
||||||
|
|
||||||
|
// 헤더 액션 버튼
|
||||||
|
const headerActions = isViewMode ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleViewDirectConstruction}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
직접 공사 내역 보기
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleViewIndirectConstruction}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
간접 공사 내역 보기
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleViewPhotoDocument}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
사진대지 보기
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<List className="h-4 w-4 mr-2" />
|
||||||
|
목록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isLoading}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="기성청구 상세"
|
||||||
|
description="기성청구를 등록하고 관리합니다"
|
||||||
|
icon={FileText}
|
||||||
|
onBack={handleBack}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기성청구 정보 */}
|
||||||
|
<ProgressBillingInfoCard
|
||||||
|
formData={formData}
|
||||||
|
isViewMode={isViewMode}
|
||||||
|
onFieldChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 계약 정보 */}
|
||||||
|
<ContractInfoCard formData={formData} />
|
||||||
|
|
||||||
|
{/* 기성청구 내역 */}
|
||||||
|
<ProgressBillingItemTable
|
||||||
|
items={formData.billingItems}
|
||||||
|
isViewMode={isViewMode}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
selectedItems={selectedBillingItems}
|
||||||
|
onToggleSelection={handleToggleBillingItemSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAllBillingItems}
|
||||||
|
onApplySelected={handleApplySelectedBillingItems}
|
||||||
|
onItemChange={handleBillingItemChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 사진대지 */}
|
||||||
|
<PhotoTable
|
||||||
|
items={formData.photoItems}
|
||||||
|
isViewMode={isViewMode}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
selectedItems={selectedPhotoItems}
|
||||||
|
onToggleSelection={handleTogglePhotoItemSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAllPhotoItems}
|
||||||
|
onApplySelected={handleApplySelectedPhotoItems}
|
||||||
|
onPhotoSelect={handlePhotoSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>저장 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
변경사항을 저장하시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||||
|
{isLoading ? '저장 중...' : '저장'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
정말로 이 기성청구를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isLoading ? '삭제 중...' : '삭제'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 직접 공사 내역서 모달 */}
|
||||||
|
<DirectConstructionModal
|
||||||
|
open={showDirectConstructionModal}
|
||||||
|
onOpenChange={setShowDirectConstructionModal}
|
||||||
|
data={formData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 간접 공사 내역서 모달 */}
|
||||||
|
<IndirectConstructionModal
|
||||||
|
open={showIndirectConstructionModal}
|
||||||
|
onOpenChange={setShowIndirectConstructionModal}
|
||||||
|
data={formData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 사진대지 모달 */}
|
||||||
|
<PhotoDocumentModal
|
||||||
|
open={showPhotoDocumentModal}
|
||||||
|
onOpenChange={setShowPhotoDocumentModal}
|
||||||
|
data={formData}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { FileText, Pencil } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||||
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ProgressBilling, ProgressBillingStats } from './types';
|
||||||
|
import {
|
||||||
|
PROGRESS_BILLING_STATUS_OPTIONS,
|
||||||
|
PROGRESS_BILLING_SORT_OPTIONS,
|
||||||
|
PROGRESS_BILLING_STATUS_STYLES,
|
||||||
|
PROGRESS_BILLING_STATUS_LABELS,
|
||||||
|
MOCK_PARTNERS,
|
||||||
|
MOCK_SITES,
|
||||||
|
PARTNER_SITES_MAP,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
getProgressBillingList,
|
||||||
|
getProgressBillingStats,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
const tableColumns: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||||
|
{ key: 'billingNumber', label: '기성청구번호', className: 'w-[140px]' },
|
||||||
|
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||||
|
{ key: 'siteName', label: '현장명', className: 'min-w-[150px]' },
|
||||||
|
{ key: 'round', label: '회차', className: 'w-[60px] text-center' },
|
||||||
|
{ key: 'billingYearMonth', label: '기성청구연월', className: 'w-[110px] text-center' },
|
||||||
|
{ key: 'previousBilling', label: '전회기성', className: 'w-[120px] text-right' },
|
||||||
|
{ key: 'currentBilling', label: '금회기성', className: 'w-[120px] text-right' },
|
||||||
|
{ key: 'cumulativeBilling', label: '누계기성', className: 'w-[120px] text-right' },
|
||||||
|
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ProgressBillingManagementListClientProps {
|
||||||
|
initialData?: ProgressBilling[];
|
||||||
|
initialStats?: ProgressBillingStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressBillingManagementListClient({
|
||||||
|
initialData = [],
|
||||||
|
initialStats,
|
||||||
|
}: ProgressBillingManagementListClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [billings, setBillings] = useState<ProgressBilling[]>(initialData);
|
||||||
|
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// 다중선택 필터
|
||||||
|
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||||
|
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||||
|
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 [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getProgressBillingList({
|
||||||
|
size: 1000,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
}),
|
||||||
|
getProgressBillingStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setBillings(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 filteredSiteOptions: MultiSelectOption[] = useMemo(() => {
|
||||||
|
if (partnerFilters.length === 0) {
|
||||||
|
return MOCK_SITES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 거래처들의 현장 ID 수집
|
||||||
|
const availableSiteIds = new Set<string>();
|
||||||
|
partnerFilters.forEach((partnerId) => {
|
||||||
|
const siteIds = PARTNER_SITES_MAP[partnerId] || [];
|
||||||
|
siteIds.forEach((siteId) => availableSiteIds.add(siteId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return MOCK_SITES.filter((site) => availableSiteIds.has(site.value));
|
||||||
|
}, [partnerFilters]);
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredBillings = useMemo(() => {
|
||||||
|
return billings.filter((billing) => {
|
||||||
|
// 상태 탭 필터
|
||||||
|
if (activeStatTab === 'contractWaiting' &&
|
||||||
|
billing.status !== 'billing_waiting' &&
|
||||||
|
billing.status !== 'approval_waiting') return false;
|
||||||
|
if (activeStatTab === 'contractComplete' && billing.status !== 'billing_complete') return false;
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (statusFilter !== 'all' && billing.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (partnerFilters.length > 0 && !partnerFilters.includes(billing.partnerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장 필터 (다중선택)
|
||||||
|
if (siteFilters.length > 0 && !siteFilters.includes(billing.siteId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchValue) {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
billing.billingNumber.toLowerCase().includes(search) ||
|
||||||
|
billing.partnerName.toLowerCase().includes(search) ||
|
||||||
|
billing.siteName.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [billings, activeStatTab, statusFilter, partnerFilters, siteFilters, searchValue]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedBillings = useMemo(() => {
|
||||||
|
const sorted = [...filteredBillings];
|
||||||
|
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 'partnerNameAsc':
|
||||||
|
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'partnerNameDesc':
|
||||||
|
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'siteNameAsc':
|
||||||
|
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'siteNameDesc':
|
||||||
|
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [filteredBillings, sortBy]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(sortedBillings.length / itemsPerPage);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedBillings.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedBillings, 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(
|
||||||
|
(billing: ProgressBilling) => {
|
||||||
|
router.push(`/ko/construction/billing/progress-billing-management/${billing.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(e: React.MouseEvent, billingId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/ko/construction/billing/progress-billing-management/${billingId}/edit`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
const renderTableRow = useCallback(
|
||||||
|
(billing: ProgressBilling, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(billing.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={billing.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(billing)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleSelection(billing.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{billing.billingNumber}</TableCell>
|
||||||
|
<TableCell>{billing.partnerName}</TableCell>
|
||||||
|
<TableCell>{billing.siteName}</TableCell>
|
||||||
|
<TableCell className="text-center">{billing.round}차</TableCell>
|
||||||
|
<TableCell className="text-center">{billing.billingYearMonth}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(billing.previousBilling)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(billing.currentBilling)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(billing.cumulativeBilling)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${PROGRESS_BILLING_STATUS_STYLES[billing.status]}`}>
|
||||||
|
{PROGRESS_BILLING_STATUS_LABELS[billing.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, billing.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
const renderMobileCard = useCallback(
|
||||||
|
(billing: ProgressBilling, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
title={billing.siteName}
|
||||||
|
subtitle={billing.billingNumber}
|
||||||
|
badge={PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||||
|
badgeVariant="secondary"
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => handleRowClick(billing)}
|
||||||
|
details={[
|
||||||
|
{ label: '거래처', value: billing.partnerName },
|
||||||
|
{ label: '회차', value: `${billing.round}차` },
|
||||||
|
{ label: '금회기성', value: formatCurrency(billing.currentBilling) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 헤더 액션 (날짜 범위 + 퀵버튼)
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
showQuickButtons={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats 카드 데이터
|
||||||
|
const statsCardsData: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: '전체 계약',
|
||||||
|
value: stats?.total ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
onClick: () => setActiveStatTab('all'),
|
||||||
|
isActive: activeStatTab === 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약대기',
|
||||||
|
value: stats?.contractWaiting ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
onClick: () => setActiveStatTab('contractWaiting'),
|
||||||
|
isActive: activeStatTab === 'contractWaiting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약완료',
|
||||||
|
value: stats?.contractComplete ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
onClick: () => setActiveStatTab('contractComplete'),
|
||||||
|
isActive: activeStatTab === 'contractComplete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 필터 옵션들
|
||||||
|
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||||
|
|
||||||
|
// filterConfig 기반 통합 필터 시스템
|
||||||
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||||
|
{ key: 'sites', label: '현장명', type: 'multi', options: filteredSiteOptions },
|
||||||
|
{ key: 'status', label: '상태', type: 'single', options: PROGRESS_BILLING_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||||
|
{ key: 'sortBy', label: '정렬', type: 'single', options: PROGRESS_BILLING_SORT_OPTIONS.map(opt => ({ value: opt.value, label: opt.label })), allOptionLabel: '최신순' },
|
||||||
|
], [partnerOptions, filteredSiteOptions]);
|
||||||
|
|
||||||
|
// filterValues 객체
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
partners: partnerFilters,
|
||||||
|
sites: siteFilters,
|
||||||
|
status: statusFilter,
|
||||||
|
sortBy: sortBy,
|
||||||
|
}), [partnerFilters, siteFilters, statusFilter, sortBy]);
|
||||||
|
|
||||||
|
// 필터 변경 핸들러
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'partners':
|
||||||
|
setPartnerFilters(value as string[]);
|
||||||
|
// 거래처 변경 시 현장 필터 초기화
|
||||||
|
setSiteFilters([]);
|
||||||
|
break;
|
||||||
|
case 'sites':
|
||||||
|
setSiteFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
setStatusFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'sortBy':
|
||||||
|
setSortBy(value as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터 초기화 핸들러
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setPartnerFilters([]);
|
||||||
|
setSiteFilters([]);
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSortBy('latest');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 헤더 추가 액션
|
||||||
|
const tableHeaderActions = (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
총 {sortedBillings.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="기성청구관리"
|
||||||
|
description="기성청구를 등록하고 관리합니다."
|
||||||
|
icon={FileText}
|
||||||
|
headerActions={headerActions}
|
||||||
|
stats={statsCardsData}
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="기성청구 필터"
|
||||||
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchPlaceholder="기성청구번호, 거래처, 현장명 검색"
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
data={paginatedData}
|
||||||
|
allData={sortedBillings}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: sortedBillings.length,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
src/components/business/construction/progress-billing/actions.ts
Normal file
317
src/components/business/construction/progress-billing/actions.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProgressBilling,
|
||||||
|
ProgressBillingStats,
|
||||||
|
ProgressBillingStatus,
|
||||||
|
ProgressBillingDetail,
|
||||||
|
ProgressBillingDetailFormData,
|
||||||
|
} from './types';
|
||||||
|
import { MOCK_PROGRESS_BILLING_DETAIL } from './types';
|
||||||
|
import { format, subMonths } from 'date-fns';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 기성청구 데이터 생성
|
||||||
|
*/
|
||||||
|
function generateMockProgressBillings(): ProgressBilling[] {
|
||||||
|
const partners = [
|
||||||
|
{ id: '1', name: '(주)대한건설' },
|
||||||
|
{ id: '2', name: '삼성물산' },
|
||||||
|
{ id: '3', name: '현대건설' },
|
||||||
|
{ id: '4', name: 'GS건설' },
|
||||||
|
{ id: '5', name: '대림산업' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sites = [
|
||||||
|
{ id: '1', name: '강남 오피스빌딩 신축' },
|
||||||
|
{ id: '2', name: '판교 데이터센터' },
|
||||||
|
{ id: '3', name: '송도 물류센터' },
|
||||||
|
{ id: '4', name: '인천공항 터미널' },
|
||||||
|
{ id: '5', name: '부산항 창고' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statuses: ProgressBillingStatus[] = ['billing_waiting', 'approval_waiting', 'constructor_sent', 'billing_complete'];
|
||||||
|
|
||||||
|
const billings: ProgressBilling[] = [];
|
||||||
|
const baseDate = new Date(2026, 0, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const partner = partners[i % partners.length];
|
||||||
|
const site = sites[i % sites.length];
|
||||||
|
const status = statuses[i % statuses.length];
|
||||||
|
const round = (i % 12) + 1;
|
||||||
|
const monthOffset = i % 6;
|
||||||
|
const billingDate = subMonths(baseDate, monthOffset);
|
||||||
|
|
||||||
|
// 기성 금액 계산 (회차에 따라 누적)
|
||||||
|
const baseAmount = 10000000 + (i * 500000);
|
||||||
|
const previousBilling = round > 1 ? baseAmount * (round - 1) : 0;
|
||||||
|
const currentBilling = baseAmount;
|
||||||
|
const cumulativeBilling = previousBilling + currentBilling;
|
||||||
|
|
||||||
|
billings.push({
|
||||||
|
id: `billing-${i + 1}`,
|
||||||
|
billingNumber: `PB-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
round,
|
||||||
|
billingYearMonth: format(billingDate, 'yyyy-MM'),
|
||||||
|
previousBilling,
|
||||||
|
currentBilling,
|
||||||
|
cumulativeBilling,
|
||||||
|
status,
|
||||||
|
createdAt: format(billingDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
updatedAt: format(baseDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return billings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시된 목업 데이터
|
||||||
|
let cachedBillings: ProgressBilling[] | null = null;
|
||||||
|
|
||||||
|
function getMockBillings(): ProgressBilling[] {
|
||||||
|
if (!cachedBillings) {
|
||||||
|
cachedBillings = generateMockProgressBillings();
|
||||||
|
}
|
||||||
|
return cachedBillings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getProgressBillingList(params?: {
|
||||||
|
size?: number;
|
||||||
|
page?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: string;
|
||||||
|
partnerIds?: string[];
|
||||||
|
siteIds?: string[];
|
||||||
|
search?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { items: ProgressBilling[]; total: number };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
let billings = getMockBillings();
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (params?.startDate && params?.endDate) {
|
||||||
|
billings = billings.filter((billing) => {
|
||||||
|
const billingDate = billing.billingYearMonth;
|
||||||
|
return billingDate >= params.startDate!.slice(0, 7) && billingDate <= params.endDate!.slice(0, 7);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (params?.status && params.status !== 'all') {
|
||||||
|
billings = billings.filter((billing) => billing.status === params.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (params?.partnerIds && params.partnerIds.length > 0) {
|
||||||
|
billings = billings.filter((billing) => params.partnerIds!.includes(billing.partnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장 필터 (다중선택)
|
||||||
|
if (params?.siteIds && params.siteIds.length > 0) {
|
||||||
|
billings = billings.filter((billing) => params.siteIds!.includes(billing.siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
if (params?.search) {
|
||||||
|
const search = params.search.toLowerCase();
|
||||||
|
billings = billings.filter(
|
||||||
|
(billing) =>
|
||||||
|
billing.billingNumber.toLowerCase().includes(search) ||
|
||||||
|
billing.partnerName.toLowerCase().includes(search) ||
|
||||||
|
billing.siteName.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const page = params?.page || 1;
|
||||||
|
const size = params?.size || 1000;
|
||||||
|
const start = (page - 1) * size;
|
||||||
|
const paginatedBillings = billings.slice(start, start + size);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: paginatedBillings,
|
||||||
|
total: billings.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '기성청구 목록 조회에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getProgressBillingStats(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: ProgressBillingStats;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const billings = getMockBillings();
|
||||||
|
|
||||||
|
const stats: ProgressBillingStats = {
|
||||||
|
total: billings.length,
|
||||||
|
contractWaiting: billings.filter((b) => b.status === 'billing_waiting' || b.status === 'approval_waiting').length,
|
||||||
|
contractComplete: billings.filter((b) => b.status === 'billing_complete').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '기성청구 통계 조회에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getProgressBillingDetail(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: ProgressBillingDetail;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출로 대체
|
||||||
|
// const response = await apiClient.get(`/progress-billing/${id}`);
|
||||||
|
|
||||||
|
// 목업 데이터 반환
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...MOCK_PROGRESS_BILLING_DETAIL,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch progress billing detail:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '기성청구 정보를 불러오는데 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 저장 (생성/수정)
|
||||||
|
*/
|
||||||
|
export async function saveProgressBilling(
|
||||||
|
id: string | null,
|
||||||
|
data: ProgressBillingDetailFormData
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: ProgressBillingDetail;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출로 대체
|
||||||
|
// const response = id
|
||||||
|
// ? await apiClient.put(`/progress-billing/${id}`, data)
|
||||||
|
// : await apiClient.post('/progress-billing', data);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
console.log('Save progress billing:', { id, data });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...MOCK_PROGRESS_BILLING_DETAIL,
|
||||||
|
id: id || String(Date.now()),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save progress billing:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '기성청구 저장에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteProgressBilling(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출로 대체
|
||||||
|
// await apiClient.delete(`/progress-billing/${id}`);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
console.log('Delete progress billing:', id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete progress billing:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '기성청구 삭제에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상태 변경
|
||||||
|
*/
|
||||||
|
export async function updateProgressBillingStatus(
|
||||||
|
id: string,
|
||||||
|
status: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출로 대체
|
||||||
|
// await apiClient.patch(`/progress-billing/${id}/status`, { status });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
console.log('Update progress billing status:', { id, status });
|
||||||
|
|
||||||
|
// 기성청구완료 시 매출 자동 등록 로직
|
||||||
|
if (status === 'completed') {
|
||||||
|
console.log('Auto-register sales for completed billing:', id);
|
||||||
|
// TODO: 매출 자동 등록 API 호출
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update progress billing status:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '상태 변경에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { ProgressBillingDetailFormData } from '../types';
|
||||||
|
|
||||||
|
interface ContractInfoCardProps {
|
||||||
|
formData: ProgressBillingDetailFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractInfoCard({ formData }: ContractInfoCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">계약 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{/* 거래처명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>거래처명</Label>
|
||||||
|
<Input value={formData.partnerName} placeholder="회사명" disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>현장명</Label>
|
||||||
|
<Input value={formData.siteName} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 계약번호 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>계약번호</Label>
|
||||||
|
<Input value={formData.contractNumber} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공사PM */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>공사PM</Label>
|
||||||
|
<Input value={formData.constructionPM} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공사담당자 */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>공사담당자</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.constructionManagers.join(', ')}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { ProgressBillingDetailFormData, ProgressBillingStatus } from '../types';
|
||||||
|
import { PROGRESS_BILLING_STATUS_OPTIONS } from '../types';
|
||||||
|
|
||||||
|
interface ProgressBillingInfoCardProps {
|
||||||
|
formData: ProgressBillingDetailFormData;
|
||||||
|
isViewMode: boolean;
|
||||||
|
onFieldChange: (field: keyof ProgressBillingDetailFormData, value: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBillingInfoCard({
|
||||||
|
formData,
|
||||||
|
isViewMode,
|
||||||
|
onFieldChange,
|
||||||
|
}: ProgressBillingInfoCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">기성청구 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* 기성청구번호 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>기성청구번호</Label>
|
||||||
|
<Input value={formData.billingNumber} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회차 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>회차</Label>
|
||||||
|
<Input value={`${formData.billingRound}회차`} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기성청구연월 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>기성청구연월</Label>
|
||||||
|
<Input value={formData.billingYearMonth} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>상태</Label>
|
||||||
|
<Select
|
||||||
|
key={`status-${formData.status}`}
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(value) => onFieldChange('status', value as ProgressBillingStatus)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="상태 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROGRESS_BILLING_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type {
|
||||||
|
ProgressBillingDetail,
|
||||||
|
ProgressBillingDetailFormData,
|
||||||
|
ProgressBillingItem,
|
||||||
|
} from '../types';
|
||||||
|
import {
|
||||||
|
progressBillingDetailToFormData,
|
||||||
|
getEmptyProgressBillingDetailFormData,
|
||||||
|
MOCK_PROGRESS_BILLING_DETAIL,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
interface UseProgressBillingDetailFormProps {
|
||||||
|
mode: 'view' | 'edit';
|
||||||
|
billingId: string;
|
||||||
|
initialData?: ProgressBillingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgressBillingDetailForm({
|
||||||
|
mode,
|
||||||
|
billingId,
|
||||||
|
initialData,
|
||||||
|
}: UseProgressBillingDetailFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Mode flags
|
||||||
|
const isViewMode = mode === 'view';
|
||||||
|
const isEditMode = mode === 'edit';
|
||||||
|
|
||||||
|
// Form data state
|
||||||
|
const [formData, setFormData] = useState<ProgressBillingDetailFormData>(() => {
|
||||||
|
if (initialData) {
|
||||||
|
return progressBillingDetailToFormData(initialData);
|
||||||
|
}
|
||||||
|
// 목업 데이터 사용
|
||||||
|
return progressBillingDetailToFormData(MOCK_PROGRESS_BILLING_DETAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Selection states for billing items
|
||||||
|
const [selectedBillingItems, setSelectedBillingItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Selection states for photo items
|
||||||
|
const [selectedPhotoItems, setSelectedPhotoItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showDirectConstructionModal, setShowDirectConstructionModal] = useState(false);
|
||||||
|
const [showIndirectConstructionModal, setShowIndirectConstructionModal] = useState(false);
|
||||||
|
const [showPhotoDocumentModal, setShowPhotoDocumentModal] = useState(false);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
router.push('/construction/billing/progress-billing-management');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
router.push('/construction/billing/progress-billing-management/' + billingId + '/edit');
|
||||||
|
}, [router, billingId]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
router.push('/construction/billing/progress-billing-management/' + billingId);
|
||||||
|
}, [router, billingId]);
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(field: keyof ProgressBillingDetailFormData, value: string | number) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save handlers
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setShowSaveDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmSave = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: API 호출
|
||||||
|
console.log('Save billing data:', formData);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
router.push('/construction/billing/progress-billing-management/' + billingId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [formData, router, billingId]);
|
||||||
|
|
||||||
|
// Delete handlers
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: API 호출
|
||||||
|
console.log('Delete billing:', billingId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
router.push('/construction/billing/progress-billing-management');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [router, billingId]);
|
||||||
|
|
||||||
|
// Billing item handlers
|
||||||
|
const handleBillingItemChange = useCallback(
|
||||||
|
(itemId: string, field: keyof ProgressBillingItem, value: string | number) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
billingItems: prev.billingItems.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, [field]: value } : item
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleBillingItemSelection = useCallback((itemId: string) => {
|
||||||
|
setSelectedBillingItems((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(itemId)) {
|
||||||
|
newSet.delete(itemId);
|
||||||
|
} else {
|
||||||
|
newSet.add(itemId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleSelectAllBillingItems = useCallback(() => {
|
||||||
|
setSelectedBillingItems((prev) => {
|
||||||
|
if (prev.size === formData.billingItems.length) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(formData.billingItems.map((item) => item.id));
|
||||||
|
});
|
||||||
|
}, [formData.billingItems]);
|
||||||
|
|
||||||
|
const handleApplySelectedBillingItems = useCallback(() => {
|
||||||
|
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
|
||||||
|
setSelectedBillingItems(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Photo item handlers
|
||||||
|
const handleTogglePhotoItemSelection = useCallback((itemId: string) => {
|
||||||
|
setSelectedPhotoItems((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(itemId)) {
|
||||||
|
newSet.delete(itemId);
|
||||||
|
} else {
|
||||||
|
newSet.add(itemId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleSelectAllPhotoItems = useCallback(() => {
|
||||||
|
setSelectedPhotoItems((prev) => {
|
||||||
|
if (prev.size === formData.photoItems.length) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(formData.photoItems.map((item) => item.id));
|
||||||
|
});
|
||||||
|
}, [formData.photoItems]);
|
||||||
|
|
||||||
|
const handleApplySelectedPhotoItems = useCallback(() => {
|
||||||
|
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
|
||||||
|
setSelectedPhotoItems(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Photo select handler (라디오 버튼으로 사진 선택)
|
||||||
|
const handlePhotoSelect = useCallback((itemId: string, photoIndex: number) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
photoItems: prev.photoItems.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, selectedPhotoIndex: photoIndex } : item
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Modal handlers
|
||||||
|
const handleViewDirectConstruction = useCallback(() => {
|
||||||
|
setShowDirectConstructionModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewIndirectConstruction = useCallback(() => {
|
||||||
|
setShowIndirectConstructionModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewPhotoDocument = useCallback(() => {
|
||||||
|
setShowPhotoDocumentModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Mode flags
|
||||||
|
isViewMode,
|
||||||
|
isEditMode,
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
formData,
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
showDeleteDialog,
|
||||||
|
setShowDeleteDialog,
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
showDirectConstructionModal,
|
||||||
|
setShowDirectConstructionModal,
|
||||||
|
showIndirectConstructionModal,
|
||||||
|
setShowIndirectConstructionModal,
|
||||||
|
showPhotoDocumentModal,
|
||||||
|
setShowPhotoDocumentModal,
|
||||||
|
|
||||||
|
// Selection states
|
||||||
|
selectedBillingItems,
|
||||||
|
selectedPhotoItems,
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
handleBack,
|
||||||
|
handleEdit,
|
||||||
|
handleCancel,
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
handleFieldChange,
|
||||||
|
|
||||||
|
// CRUD handlers
|
||||||
|
handleSave,
|
||||||
|
handleConfirmSave,
|
||||||
|
handleDelete,
|
||||||
|
handleConfirmDelete,
|
||||||
|
|
||||||
|
// Billing item handlers
|
||||||
|
handleBillingItemChange,
|
||||||
|
handleToggleBillingItemSelection,
|
||||||
|
handleToggleSelectAllBillingItems,
|
||||||
|
handleApplySelectedBillingItems,
|
||||||
|
|
||||||
|
// Photo item handlers
|
||||||
|
handleTogglePhotoItemSelection,
|
||||||
|
handleToggleSelectAllPhotoItems,
|
||||||
|
handleApplySelectedPhotoItems,
|
||||||
|
handlePhotoSelect,
|
||||||
|
|
||||||
|
// Modal handlers
|
||||||
|
handleViewDirectConstruction,
|
||||||
|
handleViewIndirectConstruction,
|
||||||
|
handleViewPhotoDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
|
||||||
|
export * from './types';
|
||||||
|
export * from './actions';
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
VisuallyHidden,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { printArea } from '@/lib/print-utils';
|
||||||
|
import type { ProgressBillingDetailFormData } from '../types';
|
||||||
|
|
||||||
|
// 숫자 포맷팅 (천단위 콤마)
|
||||||
|
function formatNumber(num: number | undefined): string {
|
||||||
|
if (num === undefined || num === null) return '-';
|
||||||
|
return num.toLocaleString('ko-KR');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectConstructionModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data: ProgressBillingDetailFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 직접 공사 내역 아이템 타입
|
||||||
|
interface DirectConstructionItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
product: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
contractUnitPrice: number;
|
||||||
|
contractAmount: number;
|
||||||
|
prevQuantity: number;
|
||||||
|
prevAmount: number;
|
||||||
|
currentQuantity: number;
|
||||||
|
currentAmount: number;
|
||||||
|
cumulativeQuantity: number;
|
||||||
|
cumulativeAmount: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목업 데이터 생성
|
||||||
|
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
|
||||||
|
return billingItems.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || '명칭',
|
||||||
|
product: item.product || '제품명',
|
||||||
|
width: item.width || 2500,
|
||||||
|
height: item.height || 3200,
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'EA',
|
||||||
|
contractUnitPrice: 2500000,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: index < 4 ? 0 : 0.8,
|
||||||
|
prevAmount: index < 4 ? 0 : 1900000,
|
||||||
|
currentQuantity: 0.8,
|
||||||
|
currentAmount: 1900000,
|
||||||
|
cumulativeQuantity: 0.8,
|
||||||
|
cumulativeAmount: 1900000,
|
||||||
|
remark: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectConstructionModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
}: DirectConstructionModalProps) {
|
||||||
|
// 핸들러
|
||||||
|
const handleEdit = () => {
|
||||||
|
toast.info('수정 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
toast.info('삭제 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printArea({ title: '직접 공사 내역서 인쇄' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 목업 데이터
|
||||||
|
const items = generateMockItems(data.billingItems);
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||||
|
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||||
|
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>직접 공사 내역서</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||||
|
<h2 className="text-lg font-semibold">직접 공사 내역서</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
|
인쇄
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 영역 */}
|
||||||
|
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||||
|
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||||
|
{/* 상단: 제목 + 결재란 */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
{/* 좌측: 제목 및 문서 정보 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">직접 공사 내역서</h1>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 결재란 */}
|
||||||
|
<table className="border-collapse border border-gray-400 text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||||
|
결<br />재
|
||||||
|
</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기성내역 제목 */}
|
||||||
|
<div className="text-center font-bold text-lg mb-4">
|
||||||
|
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장 정보 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||||
|
<thead>
|
||||||
|
{/* 1행: 상위 헤더 */}
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]">명칭</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]">제품</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
규격 mm
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">수량</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">단위</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
계약금액
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
전회기성
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
금회기성
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
누계기성
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||||
|
</tr>
|
||||||
|
{/* 2행: 하위 헤더 */}
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-14">가로</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-14">세로</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-16">단가</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* 합계 행 */}
|
||||||
|
<tr className="bg-gray-50 font-bold">
|
||||||
|
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||||
|
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
VisuallyHidden,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { printArea } from '@/lib/print-utils';
|
||||||
|
import type { ProgressBillingDetailFormData } from '../types';
|
||||||
|
|
||||||
|
// 숫자 포맷팅 (천단위 콤마)
|
||||||
|
function formatNumber(num: number | undefined): string {
|
||||||
|
if (num === undefined || num === null) return '-';
|
||||||
|
return num.toLocaleString('ko-KR');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndirectConstructionModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data: ProgressBillingDetailFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 간접 공사 내역 아이템 타입
|
||||||
|
interface IndirectConstructionItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
unit: string;
|
||||||
|
contractQuantity: number;
|
||||||
|
contractAmount: number;
|
||||||
|
prevQuantity: number;
|
||||||
|
prevAmount: number;
|
||||||
|
currentQuantity: number;
|
||||||
|
currentAmount: number;
|
||||||
|
cumulativeQuantity: number;
|
||||||
|
cumulativeAmount: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목업 데이터 생성
|
||||||
|
function generateMockItems(): IndirectConstructionItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '국민연금',
|
||||||
|
spec: '직접노무비 × 4.50%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '건강보험',
|
||||||
|
spec: '직접노무비 × 3.545%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '노인장기요양보험료',
|
||||||
|
spec: '건강보험료 × 12.81%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '고용보험',
|
||||||
|
spec: '직접공사비 × 30% × 1.57%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '일반관리비',
|
||||||
|
spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: '안전관리비',
|
||||||
|
spec: '직접공사비 × 0.3%(일반건산)',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
name: '안전검사자',
|
||||||
|
spec: '실투입 × 양정실시',
|
||||||
|
unit: 'M/D',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
name: '신호수 및 위기감시자',
|
||||||
|
spec: '실투입 × 양정실시',
|
||||||
|
unit: 'M/D',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
name: '퇴직공제부금',
|
||||||
|
spec: '직접노무비 × 2.3%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: '폐기물처리비',
|
||||||
|
spec: '직접공사비 × 요제요율이상',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
name: '건설기계대여자금보증료',
|
||||||
|
spec: '(직접비+간접공사비) × 0.07%',
|
||||||
|
unit: '식',
|
||||||
|
contractQuantity: 1,
|
||||||
|
contractAmount: 2500000,
|
||||||
|
prevQuantity: 0,
|
||||||
|
prevAmount: 0,
|
||||||
|
currentQuantity: 0,
|
||||||
|
currentAmount: 2500000,
|
||||||
|
cumulativeQuantity: 0,
|
||||||
|
cumulativeAmount: 2500000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndirectConstructionModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
}: IndirectConstructionModalProps) {
|
||||||
|
// 핸들러
|
||||||
|
const handleEdit = () => {
|
||||||
|
toast.info('수정 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
toast.info('삭제 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printArea({ title: '간접 공사 내역서 인쇄' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 목업 데이터
|
||||||
|
const items = generateMockItems();
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||||
|
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||||
|
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>간접 공사 내역서</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||||
|
<h2 className="text-lg font-semibold">간접 공사 내역서</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
|
인쇄
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 영역 */}
|
||||||
|
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||||
|
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||||
|
{/* 상단: 제목 + 결재란 */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
{/* 좌측: 제목 및 문서 정보 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">간접 공사 내역서</h1>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 결재란 */}
|
||||||
|
<table className="border-collapse border border-gray-400 text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||||
|
결<br />재
|
||||||
|
</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기성내역 제목 */}
|
||||||
|
<div className="text-center font-bold text-lg mb-4">
|
||||||
|
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장 정보 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||||
|
<thead>
|
||||||
|
{/* 1행: 상위 헤더 */}
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]">품명</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]">규격</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14">단위</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
계약금액
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
전회기성
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
금회기성
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||||
|
누계기성
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||||
|
</tr>
|
||||||
|
{/* 2행: 하위 헤더 */}
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||||
|
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* 합계 행 */}
|
||||||
|
<tr className="bg-gray-50 font-bold">
|
||||||
|
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||||
|
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||||
|
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||||
|
<td className="border border-gray-400 px-2 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
VisuallyHidden,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { printArea } from '@/lib/print-utils';
|
||||||
|
import type { ProgressBillingDetailFormData } from '../types';
|
||||||
|
|
||||||
|
interface PhotoDocumentModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data: ProgressBillingDetailFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사진대지 아이템 타입
|
||||||
|
interface PhotoDocumentItem {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목업 데이터 생성
|
||||||
|
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
|
||||||
|
// 기존 photoItems에서 선택된 사진들을 가져오거나 목업 생성
|
||||||
|
const photos: PhotoDocumentItem[] = [];
|
||||||
|
|
||||||
|
photoItems.forEach((item) => {
|
||||||
|
if (item.photos && item.photos.length > 0) {
|
||||||
|
const selectedIndex = item.selectedPhotoIndex ?? 0;
|
||||||
|
photos.push({
|
||||||
|
id: item.id,
|
||||||
|
imageUrl: item.photos[selectedIndex] || item.photos[0],
|
||||||
|
name: item.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 최소 6개 항목 채우기 (2열 × 3행)
|
||||||
|
while (photos.length < 6) {
|
||||||
|
photos.push({
|
||||||
|
id: `mock-${photos.length}`,
|
||||||
|
imageUrl: '',
|
||||||
|
name: '명칭',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoDocumentModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
}: PhotoDocumentModalProps) {
|
||||||
|
// 핸들러
|
||||||
|
const handleEdit = () => {
|
||||||
|
toast.info('수정 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
toast.info('삭제 기능은 준비 중입니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printArea({ title: '사진대지 인쇄' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 목업 데이터
|
||||||
|
const photos = generateMockPhotos(data.photoItems);
|
||||||
|
|
||||||
|
// 2열로 그룹화
|
||||||
|
const photoRows: PhotoDocumentItem[][] = [];
|
||||||
|
for (let i = 0; i < photos.length; i += 2) {
|
||||||
|
photoRows.push(photos.slice(i, i + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] md:max-w-[900px] lg:max-w-[1000px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>사진대지</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||||
|
<h2 className="text-lg font-semibold">사진대지</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
|
인쇄
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 영역 */}
|
||||||
|
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||||
|
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||||
|
{/* 상단: 제목 + 결재란 */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
{/* 좌측: 제목 및 문서 정보 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">사진대지</h1>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 결재란 */}
|
||||||
|
<table className="border-collapse border border-gray-400 text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||||
|
결<br />재
|
||||||
|
</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||||
|
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기성신청 사진대지 제목 */}
|
||||||
|
<div className="text-center font-bold text-lg mb-4">
|
||||||
|
기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현장 정보 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사진 그리드 */}
|
||||||
|
<div className="border border-gray-400">
|
||||||
|
{photoRows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="grid grid-cols-2">
|
||||||
|
{row.map((photo, colIndex) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
|
||||||
|
>
|
||||||
|
{/* 이미지 영역 */}
|
||||||
|
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||||
|
{photo.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={photo.imageUrl}
|
||||||
|
alt={photo.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-lg">IMG</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 명칭 라벨 */}
|
||||||
|
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||||
|
<span className="text-sm font-medium">{photo.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 홀수 개일 때 빈 셀 채우기 */}
|
||||||
|
{row.length === 1 && (
|
||||||
|
<div className="border border-gray-400 border-t-0">
|
||||||
|
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
|
||||||
|
<span className="text-gray-400 text-lg">IMG</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||||
|
<span className="text-sm font-medium">명칭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { PhotoItem } from '../types';
|
||||||
|
|
||||||
|
interface PhotoTableProps {
|
||||||
|
items: PhotoItem[];
|
||||||
|
isViewMode: boolean;
|
||||||
|
isEditMode: boolean;
|
||||||
|
selectedItems: Set<string>;
|
||||||
|
onToggleSelection: (itemId: string) => void;
|
||||||
|
onToggleSelectAll: () => void;
|
||||||
|
onApplySelected: () => void;
|
||||||
|
onPhotoSelect?: (itemId: string, photoIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoTable({
|
||||||
|
items,
|
||||||
|
isViewMode,
|
||||||
|
isEditMode,
|
||||||
|
selectedItems,
|
||||||
|
onToggleSelection,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onApplySelected,
|
||||||
|
onPhotoSelect,
|
||||||
|
}: PhotoTableProps) {
|
||||||
|
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApplySelected();
|
||||||
|
toast.success('적용이 완료되었습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-lg">사진대지</CardTitle>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
총 {items.length}건{selectedItems.size > 0 && ', ' + selectedItems.size + '건 선택'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isEditMode && selectedItems.size > 0 && (
|
||||||
|
<Button size="sm" onClick={handleApply}>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={onToggleSelectAll}
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[40px]">번호</TableHead>
|
||||||
|
<TableHead className="w-[70px]">시공번호</TableHead>
|
||||||
|
<TableHead className="w-[90px]">명칭</TableHead>
|
||||||
|
<TableHead>사진</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={item.id} className="h-[280px]">
|
||||||
|
<TableCell className="align-middle">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
onCheckedChange={() => onToggleSelection(item.id)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle">{index + 1}</TableCell>
|
||||||
|
<TableCell className="align-middle">{item.constructionNumber}</TableCell>
|
||||||
|
<TableCell className="align-middle">{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.photos && item.photos.length > 0 ? (
|
||||||
|
<div className="flex gap-8 flex-1">
|
||||||
|
{item.photos.map((photo, photoIdx) => (
|
||||||
|
<label
|
||||||
|
key={photoIdx}
|
||||||
|
className="flex flex-col items-center gap-3 cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative w-full min-w-[280px] h-[200px] border-2 rounded overflow-hidden transition-all bg-muted ${
|
||||||
|
item.selectedPhotoIndex === photoIdx
|
||||||
|
? 'border-primary ring-2 ring-primary'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={photo}
|
||||||
|
alt={item.name + ' 사진 ' + (photoIdx + 1)}
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`photo-select-${item.id}`}
|
||||||
|
checked={item.selectedPhotoIndex === photoIdx}
|
||||||
|
onChange={() => onPhotoSelect?.(item.id, photoIdx)}
|
||||||
|
className="w-5 h-5 accent-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import type { ProgressBillingItem } from '../types';
|
||||||
|
import { MOCK_BILLING_NAMES } from '../types';
|
||||||
|
|
||||||
|
interface ProgressBillingItemTableProps {
|
||||||
|
items: ProgressBillingItem[];
|
||||||
|
isViewMode: boolean;
|
||||||
|
isEditMode: boolean;
|
||||||
|
selectedItems: Set<string>;
|
||||||
|
onToggleSelection: (itemId: string) => void;
|
||||||
|
onToggleSelectAll: () => void;
|
||||||
|
onApplySelected: () => void;
|
||||||
|
onItemChange: (itemId: string, field: keyof ProgressBillingItem, value: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBillingItemTable({
|
||||||
|
items,
|
||||||
|
isViewMode,
|
||||||
|
isEditMode,
|
||||||
|
selectedItems,
|
||||||
|
onToggleSelection,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onApplySelected,
|
||||||
|
onItemChange,
|
||||||
|
}: ProgressBillingItemTableProps) {
|
||||||
|
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApplySelected();
|
||||||
|
toast.success('적용이 완료되었습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-lg">기성청구 내역</CardTitle>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
총 {items.length}건{selectedItems.size > 0 && `, ${selectedItems.size}건 선택`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isEditMode && selectedItems.size > 0 && (
|
||||||
|
<Button size="sm" onClick={handleApply}>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={onToggleSelectAll}
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[60px]">번호</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">시공번호</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">명칭</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">제품</TableHead>
|
||||||
|
<TableHead className="min-w-[60px]">가로</TableHead>
|
||||||
|
<TableHead className="min-w-[60px]">세로</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">작업반장</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">시공투입일</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">시공완료일</TableHead>
|
||||||
|
<TableHead className="min-w-[80px]">수량</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">금회기성</TableHead>
|
||||||
|
<TableHead className="min-w-[60px]">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
onCheckedChange={() => onToggleSelection(item.id)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell>{item.constructionNumber}</TableCell>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditMode ? (
|
||||||
|
<Select
|
||||||
|
value={item.product}
|
||||||
|
onValueChange={(value) => onItemChange(item.id, 'product', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[80px]">
|
||||||
|
<SelectValue placeholder="제품 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_BILLING_NAMES.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
item.product
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditMode ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.width}
|
||||||
|
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
|
||||||
|
className="min-w-[50px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
item.width.toLocaleString()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditMode ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.height}
|
||||||
|
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
|
||||||
|
className="min-w-[50px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
item.height.toLocaleString()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.workTeamLeader}</TableCell>
|
||||||
|
<TableCell>{item.constructionStartDate}</TableCell>
|
||||||
|
<TableCell>{item.constructionEndDate || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditMode ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
|
||||||
|
className="min-w-[60px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
item.quantity.toLocaleString()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.currentBilling.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{item.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="text-center text-muted-foreground py-8">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
483
src/components/business/construction/progress-billing/types.ts
Normal file
483
src/components/business/construction/progress-billing/types.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* 기성청구관리 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상태
|
||||||
|
*/
|
||||||
|
export type ProgressBillingStatus =
|
||||||
|
| 'billing_waiting' // 기성청구대기
|
||||||
|
| 'approval_waiting' // 승인대기
|
||||||
|
| 'constructor_sent' // 건설사전송
|
||||||
|
| 'billing_complete'; // 기성청구완료
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상태 옵션
|
||||||
|
*/
|
||||||
|
export const PROGRESS_BILLING_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'billing_waiting', label: '기성청구대기' },
|
||||||
|
{ value: 'approval_waiting', label: '승인대기' },
|
||||||
|
{ value: 'constructor_sent', label: '건설사전송' },
|
||||||
|
{ value: 'billing_complete', label: '기성청구완료' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상태 라벨
|
||||||
|
*/
|
||||||
|
export const PROGRESS_BILLING_STATUS_LABELS: Record<ProgressBillingStatus, string> = {
|
||||||
|
billing_waiting: '기성청구대기',
|
||||||
|
approval_waiting: '승인대기',
|
||||||
|
constructor_sent: '건설사전송',
|
||||||
|
billing_complete: '기성청구완료',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상태 스타일
|
||||||
|
*/
|
||||||
|
export const PROGRESS_BILLING_STATUS_STYLES: Record<ProgressBillingStatus, string> = {
|
||||||
|
billing_waiting: 'bg-yellow-100 text-yellow-800',
|
||||||
|
approval_waiting: 'bg-blue-100 text-blue-800',
|
||||||
|
constructor_sent: 'bg-purple-100 text-purple-800',
|
||||||
|
billing_complete: 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 옵션
|
||||||
|
*/
|
||||||
|
export const PROGRESS_BILLING_SORT_OPTIONS = [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||||
|
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||||
|
{ value: 'siteNameAsc', label: '현장명 오름차순' },
|
||||||
|
{ value: 'siteNameDesc', label: '현장명 내림차순' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 항목 인터페이스
|
||||||
|
*/
|
||||||
|
export interface ProgressBilling {
|
||||||
|
id: string;
|
||||||
|
billingNumber: string;
|
||||||
|
partnerId: string;
|
||||||
|
partnerName: string;
|
||||||
|
siteId: string;
|
||||||
|
siteName: string;
|
||||||
|
round: number;
|
||||||
|
billingYearMonth: string;
|
||||||
|
previousBilling: number;
|
||||||
|
currentBilling: number;
|
||||||
|
cumulativeBilling: number;
|
||||||
|
status: ProgressBillingStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 통계 인터페이스
|
||||||
|
*/
|
||||||
|
export interface ProgressBillingStats {
|
||||||
|
total: number;
|
||||||
|
contractWaiting: number;
|
||||||
|
contractComplete: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 거래처 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_PARTNERS = [
|
||||||
|
{ value: '1', label: '(주)대한건설' },
|
||||||
|
{ value: '2', label: '삼성물산' },
|
||||||
|
{ value: '3', label: '현대건설' },
|
||||||
|
{ value: '4', label: 'GS건설' },
|
||||||
|
{ value: '5', label: '대림산업' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 현장 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_SITES = [
|
||||||
|
{ value: '1', label: '강남 오피스빌딩 신축' },
|
||||||
|
{ value: '2', label: '판교 데이터센터' },
|
||||||
|
{ value: '3', label: '송도 물류센터' },
|
||||||
|
{ value: '4', label: '인천공항 터미널' },
|
||||||
|
{ value: '5', label: '부산항 창고' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 거래처별 현장 매핑
|
||||||
|
*/
|
||||||
|
export const PARTNER_SITES_MAP: Record<string, typeof MOCK_SITES> = {
|
||||||
|
'1': [
|
||||||
|
{ value: '1', label: '강남 오피스빌딩 신축' },
|
||||||
|
{ value: '2', label: '판교 데이터센터' },
|
||||||
|
],
|
||||||
|
'2': [
|
||||||
|
{ value: '3', label: '송도 물류센터' },
|
||||||
|
],
|
||||||
|
'3': [
|
||||||
|
{ value: '4', label: '인천공항 터미널' },
|
||||||
|
{ value: '5', label: '부산항 창고' },
|
||||||
|
],
|
||||||
|
'4': [
|
||||||
|
{ value: '1', label: '강남 오피스빌딩 신축' },
|
||||||
|
{ value: '3', label: '송도 물류센터' },
|
||||||
|
],
|
||||||
|
'5': [
|
||||||
|
{ value: '2', label: '판교 데이터센터' },
|
||||||
|
{ value: '4', label: '인천공항 터미널' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 옵션 공통 타입
|
||||||
|
*/
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 내역 아이템 (테이블 로우)
|
||||||
|
*/
|
||||||
|
export interface ProgressBillingItem {
|
||||||
|
id: string;
|
||||||
|
/** 시공번호 */
|
||||||
|
constructionNumber: string;
|
||||||
|
/** 명칭 */
|
||||||
|
name: string;
|
||||||
|
/** 제품 */
|
||||||
|
product: string;
|
||||||
|
/** 가로 */
|
||||||
|
width: number;
|
||||||
|
/** 세로 */
|
||||||
|
height: number;
|
||||||
|
/** 작업반장 */
|
||||||
|
workTeamLeader: string;
|
||||||
|
/** 시공투입일 */
|
||||||
|
constructionStartDate: string;
|
||||||
|
/** 시공완료일 */
|
||||||
|
constructionEndDate: string;
|
||||||
|
/** 수량 */
|
||||||
|
quantity: number;
|
||||||
|
/** 금회기성 */
|
||||||
|
currentBilling: number;
|
||||||
|
/** 상태 */
|
||||||
|
status: string;
|
||||||
|
/** 사진 URL 목록 (최대 2장) */
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사진대지 아이템 (테이블 로우)
|
||||||
|
*/
|
||||||
|
export interface PhotoItem {
|
||||||
|
id: string;
|
||||||
|
/** 시공번호 */
|
||||||
|
constructionNumber: string;
|
||||||
|
/** 명칭 */
|
||||||
|
name: string;
|
||||||
|
/** 사진 URL 목록 (2장) */
|
||||||
|
photos: string[];
|
||||||
|
/** 선택된 사진 인덱스 */
|
||||||
|
selectedPhotoIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상세 데이터
|
||||||
|
*/
|
||||||
|
export interface ProgressBillingDetail {
|
||||||
|
/** ID */
|
||||||
|
id: string;
|
||||||
|
/** 기성청구번호 */
|
||||||
|
billingNumber: string;
|
||||||
|
/** 회차 */
|
||||||
|
billingRound: number;
|
||||||
|
/** 기성청구연월 */
|
||||||
|
billingYearMonth: string;
|
||||||
|
/** 상태 */
|
||||||
|
status: ProgressBillingStatus;
|
||||||
|
/** 거래처 ID */
|
||||||
|
partnerId: string;
|
||||||
|
/** 거래처명 */
|
||||||
|
partnerName: string;
|
||||||
|
/** 현장명 */
|
||||||
|
siteName: string;
|
||||||
|
/** 계약번호 */
|
||||||
|
contractNumber: string;
|
||||||
|
/** 계약 ID */
|
||||||
|
contractId: string;
|
||||||
|
/** 공사PM */
|
||||||
|
constructionPM: string;
|
||||||
|
/** 공사담당자 목록 */
|
||||||
|
constructionManagers: string[];
|
||||||
|
/** 기성청구 내역 목록 */
|
||||||
|
billingItems: ProgressBillingItem[];
|
||||||
|
/** 사진대지 목록 */
|
||||||
|
photoItems: PhotoItem[];
|
||||||
|
/** 생성일 */
|
||||||
|
createdAt: string;
|
||||||
|
/** 수정일 */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기성청구 상세 폼 데이터
|
||||||
|
*/
|
||||||
|
export interface ProgressBillingDetailFormData {
|
||||||
|
/** 기성청구번호 */
|
||||||
|
billingNumber: string;
|
||||||
|
/** 회차 */
|
||||||
|
billingRound: number;
|
||||||
|
/** 기성청구연월 */
|
||||||
|
billingYearMonth: string;
|
||||||
|
/** 상태 */
|
||||||
|
status: ProgressBillingStatus;
|
||||||
|
/** 거래처 ID */
|
||||||
|
partnerId: string;
|
||||||
|
/** 거래처명 */
|
||||||
|
partnerName: string;
|
||||||
|
/** 현장명 */
|
||||||
|
siteName: string;
|
||||||
|
/** 계약번호 */
|
||||||
|
contractNumber: string;
|
||||||
|
/** 계약 ID */
|
||||||
|
contractId: string;
|
||||||
|
/** 공사PM */
|
||||||
|
constructionPM: string;
|
||||||
|
/** 공사담당자 목록 */
|
||||||
|
constructionManagers: string[];
|
||||||
|
/** 기성청구 내역 목록 */
|
||||||
|
billingItems: ProgressBillingItem[];
|
||||||
|
/** 사진대지 목록 */
|
||||||
|
photoItems: PhotoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 명칭 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_BILLING_NAMES: FilterOption[] = [
|
||||||
|
{ value: '1', label: '제품명 ▼' },
|
||||||
|
{ value: '2', label: '강봉A' },
|
||||||
|
{ value: '3', label: '강봉B' },
|
||||||
|
{ value: '4', label: '철근A' },
|
||||||
|
{ value: '5', label: '철근B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 기성청구 내역 아이템 생성
|
||||||
|
*/
|
||||||
|
export function getEmptyProgressBillingItem(): ProgressBillingItem {
|
||||||
|
return {
|
||||||
|
id: String(Date.now()),
|
||||||
|
constructionNumber: '',
|
||||||
|
name: '',
|
||||||
|
product: '',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
workTeamLeader: '',
|
||||||
|
constructionStartDate: '',
|
||||||
|
constructionEndDate: '',
|
||||||
|
quantity: 0,
|
||||||
|
currentBilling: 0,
|
||||||
|
status: '',
|
||||||
|
photos: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 사진대지 아이템 생성
|
||||||
|
*/
|
||||||
|
export function getEmptyPhotoItem(): PhotoItem {
|
||||||
|
return {
|
||||||
|
id: String(Date.now()),
|
||||||
|
constructionNumber: '',
|
||||||
|
name: '',
|
||||||
|
photos: [],
|
||||||
|
selectedPhotoIndex: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 기성청구 상세 폼 데이터 생성
|
||||||
|
*/
|
||||||
|
export function getEmptyProgressBillingDetailFormData(): ProgressBillingDetailFormData {
|
||||||
|
return {
|
||||||
|
billingNumber: '',
|
||||||
|
billingRound: 1,
|
||||||
|
billingYearMonth: '',
|
||||||
|
status: 'billing_waiting',
|
||||||
|
partnerId: '',
|
||||||
|
partnerName: '',
|
||||||
|
siteName: '',
|
||||||
|
contractNumber: '',
|
||||||
|
contractId: '',
|
||||||
|
constructionPM: '',
|
||||||
|
constructionManagers: [],
|
||||||
|
billingItems: [],
|
||||||
|
photoItems: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProgressBillingDetail을 폼 데이터로 변환
|
||||||
|
*/
|
||||||
|
export function progressBillingDetailToFormData(
|
||||||
|
detail: ProgressBillingDetail
|
||||||
|
): ProgressBillingDetailFormData {
|
||||||
|
return {
|
||||||
|
billingNumber: detail.billingNumber,
|
||||||
|
billingRound: detail.billingRound,
|
||||||
|
billingYearMonth: detail.billingYearMonth,
|
||||||
|
status: detail.status,
|
||||||
|
partnerId: detail.partnerId,
|
||||||
|
partnerName: detail.partnerName,
|
||||||
|
siteName: detail.siteName,
|
||||||
|
contractNumber: detail.contractNumber,
|
||||||
|
contractId: detail.contractId,
|
||||||
|
constructionPM: detail.constructionPM,
|
||||||
|
constructionManagers: detail.constructionManagers,
|
||||||
|
billingItems: detail.billingItems,
|
||||||
|
photoItems: detail.photoItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 기성청구 상세 데이터
|
||||||
|
*/
|
||||||
|
export const MOCK_PROGRESS_BILLING_DETAIL: ProgressBillingDetail = {
|
||||||
|
id: '1',
|
||||||
|
billingNumber: '123123',
|
||||||
|
billingRound: 1,
|
||||||
|
billingYearMonth: '2025년 10월',
|
||||||
|
status: 'billing_waiting',
|
||||||
|
partnerId: '1',
|
||||||
|
partnerName: '현장명',
|
||||||
|
siteName: '현장명',
|
||||||
|
contractNumber: '123123',
|
||||||
|
contractId: '1',
|
||||||
|
constructionPM: '이름',
|
||||||
|
constructionManagers: ['이름', '이름', '이름'],
|
||||||
|
billingItems: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공완료',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공진행',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공완료',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공진행',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공완료',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공완료',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
product: '제품명 ▼',
|
||||||
|
width: 2500,
|
||||||
|
height: 3200,
|
||||||
|
workTeamLeader: '홍길동',
|
||||||
|
constructionStartDate: '2025-12-15',
|
||||||
|
constructionEndDate: '2025-12-15',
|
||||||
|
quantity: 100,
|
||||||
|
currentBilling: 1000000,
|
||||||
|
status: '시공진행',
|
||||||
|
photos: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
photoItems: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
constructionNumber: '123123',
|
||||||
|
name: 'FSS801(주차장)',
|
||||||
|
photos: [
|
||||||
|
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
|
||||||
|
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
|
||||||
|
],
|
||||||
|
selectedPhotoIndex: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2025-01-10T09:00:00Z',
|
||||||
|
updatedAt: '2025-01-10T09:00:00Z',
|
||||||
|
};
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Zap, Pencil, Trash2, FileText, CheckCircle, Clock } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } 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 { Utility, UtilityStats } from './types';
|
||||||
|
import {
|
||||||
|
UTILITY_STATUS_OPTIONS,
|
||||||
|
UTILITY_SORT_OPTIONS,
|
||||||
|
UTILITY_STATUS_STYLES,
|
||||||
|
UTILITY_STATUS_LABELS,
|
||||||
|
MOCK_PARTNERS,
|
||||||
|
MOCK_SITES,
|
||||||
|
MOCK_CONSTRUCTION_PM,
|
||||||
|
MOCK_UTILITY_TYPES,
|
||||||
|
MOCK_WORK_TEAM_LEADERS,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
getUtilityList,
|
||||||
|
getUtilityStats,
|
||||||
|
deleteUtility,
|
||||||
|
deleteUtilities,
|
||||||
|
} from './actions';
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
const tableColumns: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||||
|
{ key: 'utilityNumber', label: '공과번호', className: 'w-[120px]' },
|
||||||
|
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||||
|
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||||
|
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
|
||||||
|
{ key: 'utilityType', label: '공과', className: 'w-[80px]' },
|
||||||
|
{ key: 'scheduledDate', label: '공과예정일시', className: 'w-[110px]' },
|
||||||
|
{ key: 'amount', label: '금액', className: 'w-[100px] text-right' },
|
||||||
|
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
|
||||||
|
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
|
||||||
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface UtilityManagementListClientProps {
|
||||||
|
initialData?: Utility[];
|
||||||
|
initialStats?: UtilityStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UtilityManagementListClient({
|
||||||
|
initialData = [],
|
||||||
|
initialStats,
|
||||||
|
}: UtilityManagementListClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [utilities, setUtilities] = useState<Utility[]>(initialData);
|
||||||
|
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// 다중선택 필터 (빈 배열 = 전체)
|
||||||
|
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||||
|
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||||
|
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||||
|
const [utilityTypeFilter, setUtilityTypeFilter] = useState<string>('all');
|
||||||
|
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||||
|
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' | 'waiting' | 'complete'>('all');
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getUtilityList({
|
||||||
|
size: 1000,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
}),
|
||||||
|
getUtilityStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setUtilities(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 filteredUtilities = useMemo(() => {
|
||||||
|
return utilities.filter((utility) => {
|
||||||
|
// 상태 탭 필터
|
||||||
|
if (activeStatTab === 'waiting' && utility.status !== 'scheduled' && utility.status !== 'issued') return false;
|
||||||
|
if (activeStatTab === 'complete' && utility.status !== 'completed') return false;
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (statusFilter !== 'all' && utility.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (partnerFilters.length > 0 && !partnerFilters.includes(utility.partnerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장명 필터 (다중선택)
|
||||||
|
if (siteFilters.length > 0 && !siteFilters.includes(utility.siteId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공사PM 필터 (다중선택)
|
||||||
|
if (constructionPMFilters.length > 0 && !constructionPMFilters.includes(utility.constructionPMId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공과 유형 필터 (단일선택)
|
||||||
|
if (utilityTypeFilter !== 'all') {
|
||||||
|
const matchingType = MOCK_UTILITY_TYPES.find((t) => t.value === utilityTypeFilter);
|
||||||
|
if (!matchingType || utility.utilityType !== matchingType.label) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업반장 필터 (다중선택)
|
||||||
|
if (workTeamFilters.length > 0 && !workTeamFilters.includes(utility.workTeamLeaderId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchValue) {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
utility.utilityNumber.toLowerCase().includes(search) ||
|
||||||
|
utility.partnerName.toLowerCase().includes(search) ||
|
||||||
|
utility.siteName.toLowerCase().includes(search) ||
|
||||||
|
utility.constructionPM.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [utilities, activeStatTab, statusFilter, partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, searchValue]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedUtilities = useMemo(() => {
|
||||||
|
const sorted = [...filteredUtilities];
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'latest':
|
||||||
|
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||||
|
break;
|
||||||
|
case 'oldest':
|
||||||
|
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'partnerNameDesc':
|
||||||
|
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||||
|
break;
|
||||||
|
case 'issuedDate':
|
||||||
|
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
|
||||||
|
break;
|
||||||
|
case 'completedDate':
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (a.status === 'completed' && b.status !== 'completed') return -1;
|
||||||
|
if (a.status !== 'completed' && b.status === 'completed') return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [filteredUtilities, sortBy]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(sortedUtilities.length / itemsPerPage);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedUtilities.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedUtilities, 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((u) => u.id)));
|
||||||
|
}
|
||||||
|
}, [selectedItems.size, paginatedData]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(utility: Utility) => {
|
||||||
|
router.push(`/ko/construction/project/utility-management/${utility.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(e: React.MouseEvent, utilityId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/ko/construction/project/utility-management/${utilityId}/edit`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((e: React.MouseEvent, utilityId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTargetId(utilityId);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
if (!deleteTargetId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteUtility(deleteTargetId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('공과가 삭제되었습니다.');
|
||||||
|
setUtilities((prev) => prev.filter((u) => u.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 deleteUtilities(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 formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return dateStr.split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatAmount = (amount: number) => {
|
||||||
|
return amount.toLocaleString('ko-KR') + '원';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
const renderTableRow = useCallback(
|
||||||
|
(utility: Utility, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(utility.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={utility.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(utility)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleSelection(utility.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{utility.utilityNumber}</TableCell>
|
||||||
|
<TableCell>{utility.partnerName}</TableCell>
|
||||||
|
<TableCell>{utility.siteName}</TableCell>
|
||||||
|
<TableCell>{utility.constructionPM}</TableCell>
|
||||||
|
<TableCell>{utility.utilityType}</TableCell>
|
||||||
|
<TableCell>{formatDate(utility.scheduledDate)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatAmount(utility.amount)}</TableCell>
|
||||||
|
<TableCell>{utility.workTeamLeader}</TableCell>
|
||||||
|
<TableCell>{formatDate(utility.constructionStartDate)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${UTILITY_STATUS_STYLES[utility.status]}`}>
|
||||||
|
{UTILITY_STATUS_LABELS[utility.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, utility.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, utility.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
const renderMobileCard = useCallback(
|
||||||
|
(utility: Utility, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
title={utility.siteName}
|
||||||
|
subtitle={utility.utilityNumber}
|
||||||
|
badge={UTILITY_STATUS_LABELS[utility.status]}
|
||||||
|
badgeVariant="secondary"
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => handleRowClick(utility)}
|
||||||
|
details={[
|
||||||
|
{ label: '거래처', value: utility.partnerName },
|
||||||
|
{ label: '공사PM', value: utility.constructionPM },
|
||||||
|
{ label: '금액', value: formatAmount(utility.amount) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 헤더 액션 (날짜 선택 + 날짜 버튼 - DateRangeSelector에 내장)
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats 카드 데이터 (전체 계약, 계약 대기, 계약 완료)
|
||||||
|
const statsCardsData: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: '전체 계약',
|
||||||
|
value: stats?.totalContract ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
onClick: () => setActiveStatTab('all'),
|
||||||
|
isActive: activeStatTab === 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약 대기',
|
||||||
|
value: stats?.contractWaiting ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
onClick: () => setActiveStatTab('waiting'),
|
||||||
|
isActive: activeStatTab === 'waiting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약 완료',
|
||||||
|
value: stats?.contractComplete ?? 0,
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
onClick: () => setActiveStatTab('complete'),
|
||||||
|
isActive: activeStatTab === 'complete',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 필터 옵션들
|
||||||
|
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||||
|
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
|
||||||
|
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
|
||||||
|
const utilityTypeOptions: MultiSelectOption[] = useMemo(() => MOCK_UTILITY_TYPES, []);
|
||||||
|
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
|
||||||
|
|
||||||
|
// 필터 설정 (7개)
|
||||||
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||||
|
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
|
||||||
|
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
|
||||||
|
{ key: 'utilityType', label: '공과', type: 'single', options: utilityTypeOptions },
|
||||||
|
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
|
||||||
|
{ key: 'status', label: '상태', type: 'single', options: UTILITY_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||||
|
{ key: 'sortBy', label: '정렬', type: 'single', options: UTILITY_SORT_OPTIONS.map(o => ({ value: o.value, label: o.label })), allOptionLabel: '최신순' },
|
||||||
|
], [partnerOptions, siteOptions, constructionPMOptions, utilityTypeOptions, workTeamOptions]);
|
||||||
|
|
||||||
|
// filterValues 객체
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
partners: partnerFilters,
|
||||||
|
sites: siteFilters,
|
||||||
|
constructionPMs: constructionPMFilters,
|
||||||
|
utilityType: utilityTypeFilter,
|
||||||
|
workTeamLeaders: workTeamFilters,
|
||||||
|
status: statusFilter,
|
||||||
|
sortBy: sortBy,
|
||||||
|
}), [partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, statusFilter, sortBy]);
|
||||||
|
|
||||||
|
// 필터 변경 핸들러
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'partners':
|
||||||
|
setPartnerFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'sites':
|
||||||
|
setSiteFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'constructionPMs':
|
||||||
|
setConstructionPMFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'utilityType':
|
||||||
|
setUtilityTypeFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'workTeamLeaders':
|
||||||
|
setWorkTeamFilters(value as string[]);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
setStatusFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'sortBy':
|
||||||
|
setSortBy(value as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터 초기화 핸들러
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setPartnerFilters([]);
|
||||||
|
setSiteFilters([]);
|
||||||
|
setConstructionPMFilters([]);
|
||||||
|
setUtilityTypeFilter('all');
|
||||||
|
setWorkTeamFilters([]);
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSortBy('latest');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 헤더 추가 액션
|
||||||
|
const tableHeaderActions = (
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
총 {sortedUtilities.length}건
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="공과관리"
|
||||||
|
description="공과 목록을 관리합니다"
|
||||||
|
icon={Zap}
|
||||||
|
headerActions={headerActions}
|
||||||
|
stats={statsCardsData}
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="공과 필터"
|
||||||
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchPlaceholder="공과번호, 거래처, 현장명, 공사PM 검색"
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
data={paginatedData}
|
||||||
|
allData={sortedUtilities}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
|
onBulkDelete={handleBulkDeleteClick}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: sortedUtilities.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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import type { Utility, UtilityStats, UtilityStatus } from './types';
|
||||||
|
import { format, addDays, subDays, subMonths } from 'date-fns';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 공과 데이터 생성 (고정 데이터)
|
||||||
|
*/
|
||||||
|
function generateMockUtilities(): Utility[] {
|
||||||
|
// types.ts MOCK_PARTNERS와 일치
|
||||||
|
const partners = [
|
||||||
|
{ id: '1', name: '(주)대한건설' },
|
||||||
|
{ id: '2', name: '삼성물산' },
|
||||||
|
{ id: '3', name: '현대건설' },
|
||||||
|
{ id: '4', name: 'GS건설' },
|
||||||
|
{ id: '5', name: '대림산업' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// types.ts MOCK_SITES와 일치
|
||||||
|
const sites = [
|
||||||
|
{ id: '1', name: '강남 오피스빌딩 신축' },
|
||||||
|
{ id: '2', name: '판교 데이터센터' },
|
||||||
|
{ id: '3', name: '송도 물류센터' },
|
||||||
|
{ id: '4', name: '인천공항 터미널' },
|
||||||
|
{ id: '5', name: '부산항 창고' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// types.ts MOCK_CONSTRUCTION_PM과 일치
|
||||||
|
const constructionPMs = [
|
||||||
|
{ id: '1', name: '홍길동' },
|
||||||
|
{ id: '2', name: '김철수' },
|
||||||
|
{ id: '3', name: '이영희' },
|
||||||
|
{ id: '4', name: '박민수' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// types.ts MOCK_UTILITY_TYPES와 일치
|
||||||
|
const utilityTypes = ['전기공과', '수도공과', '가스공과', '통신공과', '난방공과'];
|
||||||
|
|
||||||
|
// types.ts MOCK_WORK_TEAM_LEADERS와 일치
|
||||||
|
const workTeamLeaders = [
|
||||||
|
{ id: '1', name: '이반장' },
|
||||||
|
{ id: '2', name: '김반장' },
|
||||||
|
{ id: '3', name: '박반장' },
|
||||||
|
{ id: '4', name: '최반장' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statuses: UtilityStatus[] = ['scheduled', 'issued', 'completed', 'expired'];
|
||||||
|
|
||||||
|
const utilities: Utility[] = [];
|
||||||
|
// 고정 기준일 (2026-01-06)
|
||||||
|
const baseDate = new Date(2026, 0, 6);
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const partner = partners[i % partners.length];
|
||||||
|
const site = sites[i % sites.length];
|
||||||
|
const pm = constructionPMs[i % constructionPMs.length];
|
||||||
|
const workTeamLeader = workTeamLeaders[i % workTeamLeaders.length];
|
||||||
|
const status = statuses[i % statuses.length];
|
||||||
|
const utilityType = utilityTypes[i % utilityTypes.length];
|
||||||
|
|
||||||
|
// 날짜도 index 기반으로 고정
|
||||||
|
const monthOffset = i % 3;
|
||||||
|
const dayOffset = (i * 3) % 30;
|
||||||
|
const periodStart = subMonths(addDays(baseDate, -dayOffset), monthOffset);
|
||||||
|
const periodEnd = addDays(periodStart, 10 + (i % 20));
|
||||||
|
const scheduledDate = addDays(periodStart, i % 5);
|
||||||
|
const constructionStartDate = addDays(periodStart, i % 7);
|
||||||
|
|
||||||
|
utilities.push({
|
||||||
|
id: `utility-${i + 1}`,
|
||||||
|
utilityNumber: `UTL-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
constructionPMId: pm.id,
|
||||||
|
constructionPM: pm.name,
|
||||||
|
utilityType,
|
||||||
|
scheduledDate: format(scheduledDate, 'yyyy-MM-dd'),
|
||||||
|
amount: 100000 + (i * 50000) % 900000, // 100,000 ~ 1,000,000 고정 패턴
|
||||||
|
workTeamLeaderId: workTeamLeader.id,
|
||||||
|
workTeamLeader: workTeamLeader.name,
|
||||||
|
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
|
||||||
|
status,
|
||||||
|
periodStart: format(periodStart, 'yyyy-MM-dd'),
|
||||||
|
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
|
||||||
|
createdAt: format(subDays(periodStart, i % 10), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||||
|
updatedAt: format(baseDate, 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return utilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시된 목업 데이터
|
||||||
|
let cachedUtilities: Utility[] | null = null;
|
||||||
|
|
||||||
|
function getMockUtilities(): Utility[] {
|
||||||
|
if (!cachedUtilities) {
|
||||||
|
cachedUtilities = generateMockUtilities();
|
||||||
|
}
|
||||||
|
return cachedUtilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getUtilityList(params?: {
|
||||||
|
size?: number;
|
||||||
|
page?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { items: Utility[]; total: number };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
let utilities = getMockUtilities();
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (params?.startDate && params?.endDate) {
|
||||||
|
utilities = utilities.filter((utility) => {
|
||||||
|
return utility.periodStart >= params.startDate! && utility.periodEnd <= params.endDate!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (params?.status && params.status !== 'all') {
|
||||||
|
utilities = utilities.filter((utility) => utility.status === params.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 거래처 필터
|
||||||
|
if (params?.partnerId && params.partnerId !== 'all') {
|
||||||
|
utilities = utilities.filter((utility) => utility.partnerId === params.partnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
if (params?.search) {
|
||||||
|
const search = params.search.toLowerCase();
|
||||||
|
utilities = utilities.filter(
|
||||||
|
(utility) =>
|
||||||
|
utility.utilityNumber.toLowerCase().includes(search) ||
|
||||||
|
utility.partnerName.toLowerCase().includes(search) ||
|
||||||
|
utility.siteName.toLowerCase().includes(search) ||
|
||||||
|
utility.constructionPM.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const page = params?.page || 1;
|
||||||
|
const size = params?.size || 1000;
|
||||||
|
const start = (page - 1) * size;
|
||||||
|
const paginatedUtilities = utilities.slice(start, start + size);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: paginatedUtilities,
|
||||||
|
total: utilities.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과 목록 조회에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 통계 조회 (상단 카드용)
|
||||||
|
*/
|
||||||
|
export async function getUtilityStats(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: UtilityStats;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const utilities = getMockUtilities();
|
||||||
|
|
||||||
|
// 상단 카드: 전체 계약, 계약 대기, 계약 완료
|
||||||
|
const stats: UtilityStats = {
|
||||||
|
totalContract: utilities.length,
|
||||||
|
contractWaiting: utilities.filter((u) => u.status === 'scheduled' || u.status === 'issued').length,
|
||||||
|
contractComplete: utilities.filter((u) => u.status === 'completed').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과 통계 조회에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteUtility(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (cachedUtilities) {
|
||||||
|
cachedUtilities = cachedUtilities.filter((u) => u.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과 삭제에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 일괄 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteUtilities(ids: string[]): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deletedCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (cachedUtilities) {
|
||||||
|
const beforeCount = cachedUtilities.length;
|
||||||
|
cachedUtilities = cachedUtilities.filter((u) => !ids.includes(u.id));
|
||||||
|
const deletedCount = beforeCount - cachedUtilities.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deletedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deletedCount: ids.length,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과 일괄 삭제에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getUtilityDetail(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Utility;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const utilities = getMockUtilities();
|
||||||
|
const utility = utilities.find((u) => u.id === id);
|
||||||
|
|
||||||
|
if (!utility) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과를 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: utility,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '공과 상세 조회에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 공과관리 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as UtilityManagementListClient } from './UtilityManagementListClient';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Utility,
|
||||||
|
UtilityStats,
|
||||||
|
UtilityStatus,
|
||||||
|
FilterOption,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
UTILITY_STATUS_OPTIONS,
|
||||||
|
UTILITY_STATUS_LABELS,
|
||||||
|
UTILITY_STATUS_STYLES,
|
||||||
|
UTILITY_SORT_OPTIONS,
|
||||||
|
MOCK_PARTNERS,
|
||||||
|
MOCK_SITES,
|
||||||
|
MOCK_CONSTRUCTION_PM,
|
||||||
|
MOCK_UTILITY_TYPES,
|
||||||
|
MOCK_WORK_TEAM_LEADERS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getUtilityList,
|
||||||
|
getUtilityStats,
|
||||||
|
deleteUtility,
|
||||||
|
deleteUtilities,
|
||||||
|
getUtilityDetail,
|
||||||
|
} from './actions';
|
||||||
209
src/components/business/construction/utility-management/types.ts
Normal file
209
src/components/business/construction/utility-management/types.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* 공과관리 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 상태
|
||||||
|
*/
|
||||||
|
export type UtilityStatus = 'scheduled' | 'issued' | 'completed' | 'expired';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 데이터
|
||||||
|
*/
|
||||||
|
export interface Utility {
|
||||||
|
/** 공과 ID */
|
||||||
|
id: string;
|
||||||
|
/** 공과번호 */
|
||||||
|
utilityNumber: string;
|
||||||
|
/** 거래처 ID */
|
||||||
|
partnerId: string;
|
||||||
|
/** 거래처명 */
|
||||||
|
partnerName: string;
|
||||||
|
/** 현장 ID */
|
||||||
|
siteId: string;
|
||||||
|
/** 현장명 */
|
||||||
|
siteName: string;
|
||||||
|
/** 공사PM ID */
|
||||||
|
constructionPMId: string;
|
||||||
|
/** 공사PM */
|
||||||
|
constructionPM: string;
|
||||||
|
/** 공과 유형 (품목유형의 공과인 목록) */
|
||||||
|
utilityType: string;
|
||||||
|
/** 공과예정일시 */
|
||||||
|
scheduledDate: string;
|
||||||
|
/** 금액 */
|
||||||
|
amount: number;
|
||||||
|
/** 작업반장 ID */
|
||||||
|
workTeamLeaderId: string;
|
||||||
|
/** 작업반장 */
|
||||||
|
workTeamLeader: string;
|
||||||
|
/** 시공투입일 */
|
||||||
|
constructionStartDate: string;
|
||||||
|
/** 상태 */
|
||||||
|
status: UtilityStatus;
|
||||||
|
/** 기간 (시작일) - 달력용 */
|
||||||
|
periodStart: string;
|
||||||
|
/** 기간 (종료일) - 달력용 */
|
||||||
|
periodEnd: string;
|
||||||
|
/** 생성일 */
|
||||||
|
createdAt: string;
|
||||||
|
/** 수정일 */
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 통계 (상단 카드)
|
||||||
|
*/
|
||||||
|
export interface UtilityStats {
|
||||||
|
/** 전체 계약 */
|
||||||
|
totalContract: number;
|
||||||
|
/** 계약 대기 */
|
||||||
|
contractWaiting: number;
|
||||||
|
/** 계약 완료 */
|
||||||
|
contractComplete: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 상태 옵션
|
||||||
|
*/
|
||||||
|
export const UTILITY_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'scheduled', label: '공과예정' },
|
||||||
|
{ value: 'issued', label: '공과발행' },
|
||||||
|
{ value: 'completed', label: '공과완료' },
|
||||||
|
{ value: 'expired', label: '공과만료' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 상태 라벨
|
||||||
|
*/
|
||||||
|
export const UTILITY_STATUS_LABELS: Record<UtilityStatus, string> = {
|
||||||
|
scheduled: '공과예정',
|
||||||
|
issued: '공과발행',
|
||||||
|
completed: '공과완료',
|
||||||
|
expired: '공과만료',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공과 상태 스타일
|
||||||
|
*/
|
||||||
|
export const UTILITY_STATUS_STYLES: Record<UtilityStatus, string> = {
|
||||||
|
scheduled: 'bg-yellow-100 text-yellow-800',
|
||||||
|
issued: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
expired: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 옵션
|
||||||
|
*/
|
||||||
|
export const UTILITY_SORT_OPTIONS = [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||||
|
{ value: 'issuedDate', label: '공과발행일' },
|
||||||
|
{ value: 'completedDate', label: '공과완료' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 색상 팔레트 (10가지 고정 색상)
|
||||||
|
*/
|
||||||
|
export const SCHEDULE_COLOR_PALETTE = [
|
||||||
|
{ name: 'blue', bg: 'bg-blue-500', text: 'text-white', hex: '#3b82f6' },
|
||||||
|
{ name: 'green', bg: 'bg-green-500', text: 'text-white', hex: '#22c55e' },
|
||||||
|
{ name: 'yellow', bg: 'bg-yellow-500', text: 'text-white', hex: '#eab308' },
|
||||||
|
{ name: 'red', bg: 'bg-red-500', text: 'text-white', hex: '#ef4444' },
|
||||||
|
{ name: 'purple', bg: 'bg-purple-500', text: 'text-white', hex: '#a855f7' },
|
||||||
|
{ name: 'pink', bg: 'bg-pink-500', text: 'text-white', hex: '#ec4899' },
|
||||||
|
{ name: 'orange', bg: 'bg-orange-500', text: 'text-white', hex: '#f97316' },
|
||||||
|
{ name: 'teal', bg: 'bg-teal-500', text: 'text-white', hex: '#14b8a6' },
|
||||||
|
{ name: 'indigo', bg: 'bg-indigo-500', text: 'text-white', hex: '#6366f1' },
|
||||||
|
{ name: 'cyan', bg: 'bg-cyan-500', text: 'text-white', hex: '#06b6d4' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공사PM별 색상 매핑
|
||||||
|
*/
|
||||||
|
const CONSTRUCTION_PM_COLOR_MAP: Record<string, string> = {
|
||||||
|
'홍길동': 'blue',
|
||||||
|
'김철수': 'green',
|
||||||
|
'이영희': 'pink',
|
||||||
|
'박민수': 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공사PM 이름 기반 스케줄 색상 반환
|
||||||
|
*/
|
||||||
|
export function getScheduleColorByPM(pmName: string): string {
|
||||||
|
if (CONSTRUCTION_PM_COLOR_MAP[pmName]) {
|
||||||
|
return CONSTRUCTION_PM_COLOR_MAP[pmName];
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < pmName.length; i++) {
|
||||||
|
hash = pmName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % SCHEDULE_COLOR_PALETTE.length;
|
||||||
|
return SCHEDULE_COLOR_PALETTE[index].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 옵션 공통 타입
|
||||||
|
*/
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 거래처 목록 (매입 거래처)
|
||||||
|
*/
|
||||||
|
export const MOCK_PARTNERS: FilterOption[] = [
|
||||||
|
{ value: '1', label: '(주)대한건설' },
|
||||||
|
{ value: '2', label: '삼성물산' },
|
||||||
|
{ value: '3', label: '현대건설' },
|
||||||
|
{ value: '4', label: 'GS건설' },
|
||||||
|
{ value: '5', label: '대림산업' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 현장 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_SITES: FilterOption[] = [
|
||||||
|
{ value: '1', label: '강남 오피스빌딩 신축' },
|
||||||
|
{ value: '2', label: '판교 데이터센터' },
|
||||||
|
{ value: '3', label: '송도 물류센터' },
|
||||||
|
{ value: '4', label: '인천공항 터미널' },
|
||||||
|
{ value: '5', label: '부산항 창고' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 공사PM 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_CONSTRUCTION_PM: FilterOption[] = [
|
||||||
|
{ value: '1', label: '홍길동' },
|
||||||
|
{ value: '2', label: '김철수' },
|
||||||
|
{ value: '3', label: '이영희' },
|
||||||
|
{ value: '4', label: '박민수' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 공과 유형 목록 (품목유형의 공과인 목록)
|
||||||
|
*/
|
||||||
|
export const MOCK_UTILITY_TYPES: FilterOption[] = [
|
||||||
|
{ value: '1', label: '전기공과' },
|
||||||
|
{ value: '2', label: '수도공과' },
|
||||||
|
{ value: '3', label: '가스공과' },
|
||||||
|
{ value: '4', label: '통신공과' },
|
||||||
|
{ value: '5', label: '난방공과' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목업 작업반장 목록
|
||||||
|
*/
|
||||||
|
export const MOCK_WORK_TEAM_LEADERS: FilterOption[] = [
|
||||||
|
{ value: '1', label: '이반장' },
|
||||||
|
{ value: '2', label: '김반장' },
|
||||||
|
{ value: '3', label: '박반장' },
|
||||||
|
{ value: '4', label: '최반장' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Users, Eye, FileText, Clock, CheckCircle } 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 { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||||
|
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 type { WorkerStatus, WorkerStatusStats } from './types';
|
||||||
|
import {
|
||||||
|
WORKER_CATEGORY_OPTIONS,
|
||||||
|
WORKER_CATEGORY_LABELS,
|
||||||
|
WORKER_STATUS_OPTIONS,
|
||||||
|
WORKER_STATUS_LABELS,
|
||||||
|
WORKER_STATUS_STYLES,
|
||||||
|
WORKER_SORT_OPTIONS,
|
||||||
|
MOCK_WORKER_PARTNERS,
|
||||||
|
MOCK_WORKER_SITES,
|
||||||
|
MOCK_WORKER_DEPARTMENTS,
|
||||||
|
MOCK_WORKER_NAMES,
|
||||||
|
} from './types';
|
||||||
|
import { getWorkerStatusList, getWorkerStatusStats } from './actions';
|
||||||
|
|
||||||
|
// 테이블 컬럼 정의
|
||||||
|
const tableColumns: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||||
|
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||||
|
{ key: 'siteName', label: '현장', className: 'min-w-[100px]' },
|
||||||
|
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'department', label: '부서', className: 'w-[80px]' },
|
||||||
|
{ key: 'workerName', label: '이름', className: 'w-[80px]' },
|
||||||
|
{ key: 'baseDate', label: '기준일', className: 'w-[100px]' },
|
||||||
|
{ key: 'checkInTime', label: '출근', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'checkOutTime', label: '퇴근', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'constructionNumber', label: '시공번호', className: 'w-[120px]' },
|
||||||
|
{ key: 'laborCost', label: '노임', className: 'w-[100px] text-right' },
|
||||||
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface WorkerStatusListClientProps {
|
||||||
|
initialData?: WorkerStatus[];
|
||||||
|
initialStats?: WorkerStatusStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkerStatusListClient({
|
||||||
|
initialData = [],
|
||||||
|
initialStats,
|
||||||
|
}: WorkerStatusListClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [workers, setWorkers] = useState<WorkerStatus[]>(initialData);
|
||||||
|
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
// 다중선택 필터 (빈 배열 = 전체)
|
||||||
|
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||||
|
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||||
|
const [departmentFilters, setDepartmentFilters] = useState<string[]>([]);
|
||||||
|
const [nameFilters, setNameFilters] = useState<string[]>([]);
|
||||||
|
// 단일선택 필터
|
||||||
|
const [categoryFilter, setCategoryFilter] = 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 [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [listResult, statsResult] = await Promise.all([
|
||||||
|
getWorkerStatusList({
|
||||||
|
size: 1000,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
}),
|
||||||
|
getWorkerStatusStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listResult.success && listResult.data) {
|
||||||
|
setWorkers(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]);
|
||||||
|
|
||||||
|
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||||
|
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_WORKER_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||||
|
[]);
|
||||||
|
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_WORKER_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||||
|
[]);
|
||||||
|
const departmentOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_WORKER_DEPARTMENTS.map(d => ({ value: d.value, label: d.label })),
|
||||||
|
[]);
|
||||||
|
const nameOptions: MultiSelectOption[] = useMemo(() =>
|
||||||
|
MOCK_WORKER_NAMES.map(n => ({ value: n.value, label: n.label })),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
// 필터링된 데이터
|
||||||
|
const filteredWorkers = useMemo(() => {
|
||||||
|
return workers.filter((item) => {
|
||||||
|
// 상태 탭 필터 (계약상태)
|
||||||
|
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
||||||
|
|
||||||
|
// 구분 필터
|
||||||
|
if (categoryFilter !== 'all' && item.category !== categoryFilter) return false;
|
||||||
|
|
||||||
|
// 상태 필터 (출근상태)
|
||||||
|
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// 거래처 필터 (다중선택)
|
||||||
|
if (partnerFilters.length > 0) {
|
||||||
|
const matchingPartner = MOCK_WORKER_PARTNERS.find((p) => p.label === item.partnerName);
|
||||||
|
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현장 필터 (다중선택)
|
||||||
|
if (siteFilters.length > 0) {
|
||||||
|
const matchingSite = MOCK_WORKER_SITES.find((s) => s.label === item.siteName);
|
||||||
|
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부서 필터 (다중선택)
|
||||||
|
if (departmentFilters.length > 0) {
|
||||||
|
const matchingDept = MOCK_WORKER_DEPARTMENTS.find((d) => d.label === item.department);
|
||||||
|
if (!matchingDept || !departmentFilters.includes(matchingDept.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이름 필터 (다중선택)
|
||||||
|
if (nameFilters.length > 0) {
|
||||||
|
const matchingName = MOCK_WORKER_NAMES.find((n) => n.label === item.workerName);
|
||||||
|
if (!matchingName || !nameFilters.includes(matchingName.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchValue) {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.partnerName.toLowerCase().includes(search) ||
|
||||||
|
item.siteName.toLowerCase().includes(search) ||
|
||||||
|
item.department.toLowerCase().includes(search) ||
|
||||||
|
item.workerName.toLowerCase().includes(search) ||
|
||||||
|
item.constructionNumber.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [workers, activeStatTab, categoryFilter, statusFilter, partnerFilters, siteFilters, departmentFilters, nameFilters, searchValue]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedWorkers = useMemo(() => {
|
||||||
|
const sorted = [...filteredWorkers];
|
||||||
|
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 'partnerAsc':
|
||||||
|
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||||
|
break;
|
||||||
|
case 'partnerDesc':
|
||||||
|
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||||
|
break;
|
||||||
|
case 'siteAsc':
|
||||||
|
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName));
|
||||||
|
break;
|
||||||
|
case 'siteDesc':
|
||||||
|
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [filteredWorkers, sortBy]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(sortedWorkers.length / itemsPerPage);
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedWorkers.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedWorkers, 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((c) => c.id)));
|
||||||
|
}
|
||||||
|
}, [selectedItems.size, paginatedData]);
|
||||||
|
|
||||||
|
const handleViewDetail = useCallback(
|
||||||
|
(e: React.MouseEvent, itemId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/ko/construction/project/worker-status/${itemId}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(item: WorkerStatus) => {
|
||||||
|
router.push(`/ko/construction/project/worker-status/${item.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 시간 포맷
|
||||||
|
const formatTime = (timeStr: string | null) => {
|
||||||
|
if (!timeStr) return '-';
|
||||||
|
return timeStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 금액 포맷
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 행 렌더링
|
||||||
|
const renderTableRow = useCallback(
|
||||||
|
(item: WorkerStatus, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(item.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{item.partnerName}</TableCell>
|
||||||
|
<TableCell>{item.siteName}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||||
|
{WORKER_CATEGORY_LABELS[item.category]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.department}</TableCell>
|
||||||
|
<TableCell>{item.workerName}</TableCell>
|
||||||
|
<TableCell>{item.baseDate}</TableCell>
|
||||||
|
<TableCell className="text-center">{formatTime(item.checkInTime)}</TableCell>
|
||||||
|
<TableCell className="text-center">{formatTime(item.checkOutTime)}</TableCell>
|
||||||
|
<TableCell>{item.constructionNumber}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(item.laborCost)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${WORKER_STATUS_STYLES[item.status]}`}>
|
||||||
|
{WORKER_STATUS_LABELS[item.status]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{isSelected && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => handleViewDetail(e, item.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedItems, handleToggleSelection, handleRowClick, handleViewDetail]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모바일 카드 렌더링
|
||||||
|
const renderMobileCard = useCallback(
|
||||||
|
(item: WorkerStatus, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
title={item.workerName}
|
||||||
|
subtitle={`${item.partnerName} - ${item.siteName}`}
|
||||||
|
badge={WORKER_STATUS_LABELS[item.status]}
|
||||||
|
badgeVariant="secondary"
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
details={[
|
||||||
|
{ label: '구분', value: WORKER_CATEGORY_LABELS[item.category] },
|
||||||
|
{ label: '부서', value: item.department },
|
||||||
|
{ label: '기준일', value: item.baseDate },
|
||||||
|
{ label: '노임', value: formatCurrency(item.laborCost) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 헤더 액션 (DateRangeSelector)
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 통계 카드 클릭 핸들러
|
||||||
|
const handleStatClick = useCallback((tab: 'all' | 'all_contract' | 'pending' | 'completed') => {
|
||||||
|
setActiveStatTab(tab);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 통계 카드 데이터
|
||||||
|
const statsCardsData: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: '전체 계약',
|
||||||
|
value: stats?.allContract ?? 0,
|
||||||
|
icon: FileText,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
onClick: () => handleStatClick('all_contract'),
|
||||||
|
isActive: activeStatTab === 'all_contract',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약대기',
|
||||||
|
value: stats?.pending ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: 'text-yellow-600',
|
||||||
|
onClick: () => handleStatClick('pending'),
|
||||||
|
isActive: activeStatTab === 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '계약완료',
|
||||||
|
value: stats?.completed ?? 0,
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
onClick: () => handleStatClick('completed'),
|
||||||
|
isActive: activeStatTab === 'completed',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 테이블 헤더 액션 (7개 필터)
|
||||||
|
const tableHeaderActions = (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end w-full">
|
||||||
|
{/* 1. 거래처 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={partnerOptions}
|
||||||
|
value={partnerFilters}
|
||||||
|
onChange={setPartnerFilters}
|
||||||
|
placeholder="거래처"
|
||||||
|
searchPlaceholder="거래처 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. 현장명 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={siteOptions}
|
||||||
|
value={siteFilters}
|
||||||
|
onChange={setSiteFilters}
|
||||||
|
placeholder="현장명"
|
||||||
|
searchPlaceholder="현장명 검색..."
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. 구분 필터 (단일선택) */}
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="구분" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WORKER_CATEGORY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 4. 부서 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={departmentOptions}
|
||||||
|
value={departmentFilters}
|
||||||
|
onChange={setDepartmentFilters}
|
||||||
|
placeholder="부서"
|
||||||
|
searchPlaceholder="부서 검색..."
|
||||||
|
className="w-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 5. 이름 필터 (다중선택) */}
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={nameOptions}
|
||||||
|
value={nameFilters}
|
||||||
|
onChange={setNameFilters}
|
||||||
|
placeholder="이름"
|
||||||
|
searchPlaceholder="이름 검색..."
|
||||||
|
className="w-[100px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 6. 상태 필터 (단일선택) */}
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WORKER_STATUS_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 7. 정렬 (단일선택) */}
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="정렬" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WORKER_SORT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="작업인력현황"
|
||||||
|
description="작업인력현황을 확인합니다"
|
||||||
|
icon={Users}
|
||||||
|
headerActions={headerActions}
|
||||||
|
stats={statsCardsData}
|
||||||
|
tableHeaderActions={tableHeaderActions}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchPlaceholder="거래처, 현장, 부서, 이름, 시공번호 검색"
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
data={paginatedData}
|
||||||
|
allData={sortedWorkers}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: sortedWorkers.length,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/business/construction/worker-status/actions.ts
Normal file
111
src/components/business/construction/worker-status/actions.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import type { WorkerStatus, WorkerStatusStats } from './types';
|
||||||
|
|
||||||
|
// Mock 데이터 생성
|
||||||
|
const generateMockData = (): WorkerStatus[] => {
|
||||||
|
const partners = ['현성엘리', '삼성전자', '대우건설', 'LG전자'];
|
||||||
|
const sites = ['문정교회', '강남빌딩', '서초타워', '판교오피스'];
|
||||||
|
const departments = ['시공', '설계', '관리', '영업'];
|
||||||
|
const names = ['김정수', '김동혁', '이영희', '박철수', '최민수', '홍길동', '이순신', '강감찬'];
|
||||||
|
const categories: ('foreman' | 'worker')[] = ['foreman', 'worker'];
|
||||||
|
const statuses: ('absent' | 'checked_in' | 'checked_out' | 'early_leave' | 'other')[] = [
|
||||||
|
'absent', 'checked_in', 'checked_out', 'early_leave', 'other'
|
||||||
|
];
|
||||||
|
const contractStatuses: ('all_contract' | 'pending' | 'completed')[] = ['all_contract', 'pending', 'completed'];
|
||||||
|
|
||||||
|
const mockData: WorkerStatus[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= 30; i++) {
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
const hasCheckIn = status !== 'absent';
|
||||||
|
const hasCheckOut = status === 'checked_out' || status === 'early_leave';
|
||||||
|
|
||||||
|
mockData.push({
|
||||||
|
id: `worker-${i}`,
|
||||||
|
partnerName: partners[Math.floor(Math.random() * partners.length)],
|
||||||
|
siteName: sites[Math.floor(Math.random() * sites.length)],
|
||||||
|
category: categories[Math.floor(Math.random() * categories.length)],
|
||||||
|
department: departments[Math.floor(Math.random() * departments.length)],
|
||||||
|
workerName: names[Math.floor(Math.random() * names.length)],
|
||||||
|
baseDate: `2025-09-${String(Math.floor(Math.random() * 30) + 1).padStart(2, '0')}`,
|
||||||
|
checkInTime: hasCheckIn ? `${String(8 + Math.floor(Math.random() * 2)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}` : null,
|
||||||
|
checkOutTime: hasCheckOut ? `${String(17 + Math.floor(Math.random() * 3)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}` : null,
|
||||||
|
constructionNumber: `CON-2025-${String(i).padStart(4, '0')}`,
|
||||||
|
laborCost: (100000 + Math.floor(Math.random() * 100000)),
|
||||||
|
status,
|
||||||
|
contractStatus: contractStatuses[Math.floor(Math.random() * contractStatuses.length)],
|
||||||
|
createdAt: new Date(2025, 8, i).toISOString(),
|
||||||
|
updatedAt: new Date(2025, 8, i).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockData;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetWorkerStatusListParams {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetWorkerStatusListResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
items: WorkerStatus[];
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerStatusList(
|
||||||
|
params: GetWorkerStatusListParams = {}
|
||||||
|
): Promise<GetWorkerStatusListResult> {
|
||||||
|
try {
|
||||||
|
// Mock 데이터 반환
|
||||||
|
const mockData = generateMockData();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: mockData,
|
||||||
|
totalItems: mockData.length,
|
||||||
|
totalPages: Math.ceil(mockData.length / (params.size || 20)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch worker status list:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '작업인력현황 목록을 불러오는데 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetWorkerStatusStatsResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: WorkerStatusStats;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkerStatusStats(): Promise<GetWorkerStatusStatsResult> {
|
||||||
|
try {
|
||||||
|
// Mock 통계 데이터
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
allContract: 25,
|
||||||
|
pending: 8,
|
||||||
|
completed: 17,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch worker status stats:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '통계 정보를 불러오는데 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/components/business/construction/worker-status/types.ts
Normal file
116
src/components/business/construction/worker-status/types.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// 작업인력현황 타입 정의
|
||||||
|
|
||||||
|
// 작업인력 항목
|
||||||
|
export interface WorkerStatus {
|
||||||
|
id: string;
|
||||||
|
partnerName: string; // 거래처
|
||||||
|
siteName: string; // 현장
|
||||||
|
category: 'foreman' | 'worker'; // 구분: 작업반장/작업인
|
||||||
|
department: string; // 부서
|
||||||
|
workerName: string; // 이름
|
||||||
|
baseDate: string; // 기준일
|
||||||
|
checkInTime: string | null; // 출근
|
||||||
|
checkOutTime: string | null; // 퇴근
|
||||||
|
constructionNumber: string; // 시공번호
|
||||||
|
laborCost: number; // 노임
|
||||||
|
status: 'absent' | 'checked_in' | 'checked_out' | 'early_leave' | 'other'; // 상태
|
||||||
|
contractStatus: 'all_contract' | 'pending' | 'completed'; // 계약상태
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
export interface WorkerStatusStats {
|
||||||
|
allContract: number; // 전체 계약
|
||||||
|
pending: number; // 계약대기
|
||||||
|
completed: number; // 계약완료
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구분 옵션
|
||||||
|
export const WORKER_CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'foreman', label: '작업반장' },
|
||||||
|
{ value: 'worker', label: '작업인' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WORKER_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
foreman: '작업반장',
|
||||||
|
worker: '작업인',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 옵션
|
||||||
|
export const WORKER_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'absent', label: '미출근' },
|
||||||
|
{ value: 'checked_in', label: '출근' },
|
||||||
|
{ value: 'checked_out', label: '퇴근' },
|
||||||
|
{ value: 'early_leave', label: '조퇴' },
|
||||||
|
{ value: 'other', label: '기타' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WORKER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
absent: '미출근',
|
||||||
|
checked_in: '출근',
|
||||||
|
checked_out: '퇴근',
|
||||||
|
early_leave: '조퇴',
|
||||||
|
other: '기타',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORKER_STATUS_STYLES: Record<string, string> = {
|
||||||
|
absent: 'bg-gray-100 text-gray-700',
|
||||||
|
checked_in: 'bg-green-100 text-green-700',
|
||||||
|
checked_out: 'bg-blue-100 text-blue-700',
|
||||||
|
early_leave: 'bg-yellow-100 text-yellow-700',
|
||||||
|
other: 'bg-orange-100 text-orange-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계약상태 옵션
|
||||||
|
export const CONTRACT_STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'all_contract', label: '전체 계약' },
|
||||||
|
{ value: 'pending', label: '계약대기' },
|
||||||
|
{ value: 'completed', label: '계약완료' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 정렬 옵션
|
||||||
|
export const WORKER_SORT_OPTIONS = [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'partnerAsc', label: '거래처명 오름차순' },
|
||||||
|
{ value: 'partnerDesc', label: '거래처명 내림차순' },
|
||||||
|
{ value: 'siteAsc', label: '현장명 오름차순' },
|
||||||
|
{ value: 'siteDesc', label: '현장명 내림차순' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Mock 데이터 - 거래처 목록
|
||||||
|
export const MOCK_WORKER_PARTNERS = [
|
||||||
|
{ value: 'partner1', label: '현성엘리' },
|
||||||
|
{ value: 'partner2', label: '삼성전자' },
|
||||||
|
{ value: 'partner3', label: '대우건설' },
|
||||||
|
{ value: 'partner4', label: 'LG전자' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 데이터 - 현장 목록
|
||||||
|
export const MOCK_WORKER_SITES = [
|
||||||
|
{ value: 'site1', label: '문정교회' },
|
||||||
|
{ value: 'site2', label: '강남빌딩' },
|
||||||
|
{ value: 'site3', label: '서초타워' },
|
||||||
|
{ value: 'site4', label: '판교오피스' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 데이터 - 부서 목록
|
||||||
|
export const MOCK_WORKER_DEPARTMENTS = [
|
||||||
|
{ value: 'dept1', label: '시공' },
|
||||||
|
{ value: 'dept2', label: '설계' },
|
||||||
|
{ value: 'dept3', label: '관리' },
|
||||||
|
{ value: 'dept4', label: '영업' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 데이터 - 이름 목록
|
||||||
|
export const MOCK_WORKER_NAMES = [
|
||||||
|
{ value: 'name1', label: '김정수' },
|
||||||
|
{ value: 'name2', label: '김동혁' },
|
||||||
|
{ value: 'name3', label: '이영희' },
|
||||||
|
{ value: 'name4', label: '박철수' },
|
||||||
|
{ value: 'name5', label: '최민수' },
|
||||||
|
];
|
||||||
@@ -27,61 +27,97 @@ export function CalendarHeader({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
|
<div className="flex flex-col gap-3 pb-3 border-b">
|
||||||
{/* 좌측: 타이틀 + 년월 네비게이션 */}
|
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
|
||||||
<div className="flex items-center gap-4">
|
{/* 모바일: 타이틀 / 네비게이션 + 뷰전환 / 필터 (세 줄) */}
|
||||||
{titleSlot && (
|
|
||||||
<span className="text-base font-semibold text-foreground">{titleSlot}</span>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
|
||||||
onClick={onPrevMonth}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="text-lg font-bold min-w-[120px] text-center">
|
{/* 1줄(모바일) / 좌측(PC): 타이틀 */}
|
||||||
{formatYearMonth(currentDate)}
|
{titleSlot && (
|
||||||
</span>
|
<div className="xl:hidden text-base font-semibold text-foreground">
|
||||||
|
{titleSlot}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
|
||||||
onClick={onNextMonth}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 우측: 뷰 전환 + 필터 */}
|
{/* 2줄(모바일) / 전체(PC): 네비게이션 + 뷰전환 + 필터 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
|
||||||
{/* 뷰 전환 탭 */}
|
{/* 좌측: (PC에서만 타이틀) + 네비게이션 */}
|
||||||
<div className="flex rounded-md border">
|
<div className="flex items-center gap-4">
|
||||||
{views.map((v) => (
|
{titleSlot && (
|
||||||
<button
|
<span className="hidden xl:block text-base font-semibold text-foreground">
|
||||||
key={v.value}
|
{titleSlot}
|
||||||
onClick={() => onViewChange(v.value)}
|
</span>
|
||||||
className={cn(
|
)}
|
||||||
'px-4 py-1.5 text-sm font-medium transition-colors',
|
<div className="flex items-center gap-2">
|
||||||
'first:rounded-l-md last:rounded-r-md',
|
<Button
|
||||||
view === v.value
|
variant="outline"
|
||||||
? 'bg-primary text-primary-foreground'
|
size="icon"
|
||||||
: 'hover:bg-primary/10 text-foreground'
|
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||||
)}
|
onClick={onPrevMonth}
|
||||||
>
|
>
|
||||||
{v.label}
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
))}
|
|
||||||
|
<span className="text-lg font-bold min-w-[120px] text-center">
|
||||||
|
{formatYearMonth(currentDate)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||||
|
onClick={onNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일: 뷰 전환 탭 (네비게이션 옆) */}
|
||||||
|
<div className="flex xl:hidden rounded-md border">
|
||||||
|
{views.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.value}
|
||||||
|
onClick={() => onViewChange(v.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
'first:rounded-l-md last:rounded-r-md',
|
||||||
|
view === v.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-primary/10 text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 슬롯 */}
|
{/* 우측(PC만): 뷰 전환 + 필터 */}
|
||||||
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
|
<div className="hidden xl:flex items-center gap-3">
|
||||||
|
<div className="flex rounded-md border">
|
||||||
|
{views.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.value}
|
||||||
|
onClick={() => onViewChange(v.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
'first:rounded-l-md last:rounded-r-md',
|
||||||
|
view === v.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-primary/10 text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3줄(모바일만): 필터 */}
|
||||||
|
{filterSlot && (
|
||||||
|
<div className="flex xl:hidden items-center gap-2">{filterSlot}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,9 @@ export function DateRangeSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{/* 상단: 날짜 선택 + 기간 버튼 */}
|
{/* 1줄: 날짜 + 프리셋 */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
|
||||||
|
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
|
||||||
{/* 날짜 범위 선택 (Input type="date") */}
|
{/* 날짜 범위 선택 (Input type="date") */}
|
||||||
{!hideDateInputs && (
|
{!hideDateInputs && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
@@ -145,7 +146,7 @@ export function DateRangeSelector({
|
|||||||
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
|
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
|
||||||
{!hidePresets && presets.length > 0 && (
|
{!hidePresets && presets.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="overflow-x-auto -mx-1 px-1"
|
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||||
@@ -165,9 +166,9 @@ export function DateRangeSelector({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단: 추가 액션 버튼들 */}
|
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
|
||||||
{extraActions && (
|
{extraActions && (
|
||||||
<div className="flex items-center gap-2 flex-wrap sm:justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
{extraActions}
|
{extraActions}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface MobileCardProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
badgeClassName?: string;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -31,6 +32,7 @@ export function MobileCard({
|
|||||||
description,
|
description,
|
||||||
badge,
|
badge,
|
||||||
badgeVariant = 'default',
|
badgeVariant = 'default',
|
||||||
|
badgeClassName,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -63,7 +65,7 @@ export function MobileCard({
|
|||||||
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
<div className="text-sm text-muted-foreground">{subtitle}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{badge && <Badge variant={badgeVariant}>{badge}</Badge>}
|
{badge && <Badge variant={badgeVariant} className={badgeClassName}>{badge}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
|
|||||||
335
src/components/molecules/MobileFilter.tsx
Normal file
335
src/components/molecules/MobileFilter.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 종합 필터 컴포넌트
|
||||||
|
*
|
||||||
|
* PC에서 여러 개의 필터를 모바일에서는 하나의 바텀시트로 통합
|
||||||
|
* - 단일선택(single), 다중선택(multi) 필드 지원
|
||||||
|
* - 적용된 필터 개수 배지 표시
|
||||||
|
* - 초기화/적용 버튼
|
||||||
|
* - PC와 동일한 셀렉트 박스 형태로 컴팩트하게 표시
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Filter, X, Check, RotateCcw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// 필터 옵션 타입
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 필드 설정 타입
|
||||||
|
export interface FilterFieldConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'single' | 'multi';
|
||||||
|
options: FilterOption[];
|
||||||
|
allOptionLabel?: string; // single 타입에서 "전체" 옵션 라벨 (기본: '전체')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 값 타입
|
||||||
|
export type FilterValues = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
// MobileFilter Props
|
||||||
|
export interface MobileFilterProps {
|
||||||
|
fields: FilterFieldConfig[];
|
||||||
|
values: FilterValues;
|
||||||
|
onChange: (key: string, value: string | string[]) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onApply?: () => void;
|
||||||
|
buttonLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
/** 적용된 필터를 버튼 아래 태그로 표시할지 여부 (기본: true) */
|
||||||
|
showAppliedTags?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 적용된 필터 개수 계산
|
||||||
|
*/
|
||||||
|
function countActiveFilters(
|
||||||
|
fields: FilterFieldConfig[],
|
||||||
|
values: FilterValues
|
||||||
|
): number {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = values[field.key];
|
||||||
|
|
||||||
|
if (field.type === 'single') {
|
||||||
|
// single: 'all'이 아니면 활성화
|
||||||
|
if (value && value !== 'all') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// multi: 배열에 값이 있으면 활성화
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 필드 요약 텍스트 생성
|
||||||
|
*/
|
||||||
|
function getFieldSummary(
|
||||||
|
field: FilterFieldConfig,
|
||||||
|
value: string | string[] | undefined
|
||||||
|
): string {
|
||||||
|
if (field.type === 'single') {
|
||||||
|
if (!value || value === 'all') return field.allOptionLabel || '전체';
|
||||||
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
|
return option?.label || '전체';
|
||||||
|
} else {
|
||||||
|
const arr = Array.isArray(value) ? value : [];
|
||||||
|
if (arr.length === 0) return '전체';
|
||||||
|
if (arr.length === field.options.length) return '전체';
|
||||||
|
const firstOption = field.options.find((opt) => arr.includes(opt.value));
|
||||||
|
if (arr.length === 1) return firstOption?.label || '';
|
||||||
|
return `${firstOption?.label} 외 ${arr.length - 1}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 적용된 필터 태그 목록 생성
|
||||||
|
*/
|
||||||
|
function getAppliedFilterTags(
|
||||||
|
fields: FilterFieldConfig[],
|
||||||
|
values: FilterValues
|
||||||
|
): Array<{ key: string; label: string; displayValue: string }> {
|
||||||
|
const tags: Array<{ key: string; label: string; displayValue: string }> = [];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = values[field.key];
|
||||||
|
|
||||||
|
if (field.type === 'single') {
|
||||||
|
// single: 'all'이 아니면 태그 추가
|
||||||
|
if (value && value !== 'all') {
|
||||||
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
|
if (option) {
|
||||||
|
tags.push({
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
displayValue: option.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// multi: 배열에 값이 있으면 태그 추가
|
||||||
|
const arr = Array.isArray(value) ? value : [];
|
||||||
|
if (arr.length > 0) {
|
||||||
|
const firstOption = field.options.find((opt) => arr.includes(opt.value));
|
||||||
|
const displayValue =
|
||||||
|
arr.length === 1
|
||||||
|
? firstOption?.label || ''
|
||||||
|
: `${firstOption?.label} 외 ${arr.length - 1}`;
|
||||||
|
tags.push({
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
displayValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileFilter({
|
||||||
|
fields,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onApply,
|
||||||
|
buttonLabel = '필터',
|
||||||
|
title = '검색 필터',
|
||||||
|
className,
|
||||||
|
showAppliedTags = true,
|
||||||
|
}: MobileFilterProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const activeCount = countActiveFilters(fields, values);
|
||||||
|
const appliedTags = showAppliedTags ? getAppliedFilterTags(fields, values) : [];
|
||||||
|
|
||||||
|
// 개별 필터 초기화 핸들러
|
||||||
|
const handleClearFilter = (key: string) => {
|
||||||
|
const field = fields.find((f) => f.key === key);
|
||||||
|
if (field) {
|
||||||
|
if (field.type === 'single') {
|
||||||
|
onChange(key, 'all');
|
||||||
|
} else {
|
||||||
|
onChange(key, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화 핸들러
|
||||||
|
const handleReset = () => {
|
||||||
|
onReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 적용 핸들러
|
||||||
|
const handleApply = () => {
|
||||||
|
if (onApply) {
|
||||||
|
onApply();
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* 상단: 필터 버튼 + 적용된 태그 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 필터 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('gap-2', className)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
<span>{buttonLabel}</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 min-w-5 rounded-full px-1.5 text-xs bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{activeCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 적용된 필터 태그 */}
|
||||||
|
{showAppliedTags && appliedTags.length > 0 && (
|
||||||
|
<>
|
||||||
|
{appliedTags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag.key}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 pr-1 text-xs font-normal bg-muted hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{tag.label}:</span>
|
||||||
|
<span>{tag.displayValue}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClearFilter(tag.key)}
|
||||||
|
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{/* 전체 초기화 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 Drawer (바텀시트) */}
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerContent className="max-h-[85vh] flex flex-col">
|
||||||
|
<DrawerHeader className="border-b flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
{/* 컴팩트한 셀렉트 박스 형태 - 스크롤 가능 */}
|
||||||
|
<div className="px-4 py-4 space-y-4 overflow-y-auto flex-1">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.type === 'single' ? (
|
||||||
|
// 단일선택: Select
|
||||||
|
<Select
|
||||||
|
value={(values[field.key] as string) || 'all'}
|
||||||
|
onValueChange={(value) => onChange(field.key, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={field.allOptionLabel || '전체'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{field.allOptionLabel || '전체'}
|
||||||
|
</SelectItem>
|
||||||
|
{field.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
// 다중선택: MultiSelectCombobox
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={field.options.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
}))}
|
||||||
|
value={(values[field.key] as string[]) || []}
|
||||||
|
onChange={(value) => onChange(field.key, value)}
|
||||||
|
placeholder="전체"
|
||||||
|
searchPlaceholder={`${field.label} 검색...`}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter className="border-t flex-row gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-2"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 gap-2" onClick={handleApply}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
|
|||||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -23,6 +30,8 @@ import { StatCards } from "@/components/organisms/StatCards";
|
|||||||
import { SearchFilter } from "@/components/organisms/SearchFilter";
|
import { SearchFilter } from "@/components/organisms/SearchFilter";
|
||||||
import { ScreenVersionHistory } from "@/components/organisms/ScreenVersionHistory";
|
import { ScreenVersionHistory } from "@/components/organisms/ScreenVersionHistory";
|
||||||
import { TabChip } from "@/components/atoms/TabChip";
|
import { TabChip } from "@/components/atoms/TabChip";
|
||||||
|
import { MultiSelectCombobox } from "@/components/ui/multi-select-combobox";
|
||||||
|
import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 통합 목록_버젼2
|
* 기본 통합 목록_버젼2
|
||||||
@@ -119,6 +128,18 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
|||||||
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
|
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
|
||||||
tableHeaderActions?: ReactNode;
|
tableHeaderActions?: ReactNode;
|
||||||
|
|
||||||
|
// 모바일/카드 뷰용 필터 슬롯 (xl 미만에서 카드 목록 위에 표시)
|
||||||
|
mobileFilterSlot?: ReactNode;
|
||||||
|
|
||||||
|
// ===== 새로운 통합 필터 시스템 (선택적 사용) =====
|
||||||
|
// filterConfig를 전달하면 PC는 인라인, 모바일은 바텀시트로 자동 분기
|
||||||
|
// 기존 tableHeaderActions, mobileFilterSlot과 함께 사용 가능
|
||||||
|
filterConfig?: FilterFieldConfig[];
|
||||||
|
filterValues?: FilterValues;
|
||||||
|
onFilterChange?: (key: string, value: string | string[]) => void;
|
||||||
|
onFilterReset?: () => void;
|
||||||
|
filterTitle?: string; // 모바일 필터 바텀시트 제목 (기본: "검색 필터")
|
||||||
|
|
||||||
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
|
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
|
||||||
beforeTableContent?: ReactNode;
|
beforeTableContent?: ReactNode;
|
||||||
|
|
||||||
@@ -184,6 +205,12 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tableHeaderActions,
|
tableHeaderActions,
|
||||||
|
mobileFilterSlot,
|
||||||
|
filterConfig,
|
||||||
|
filterValues,
|
||||||
|
onFilterChange,
|
||||||
|
onFilterReset,
|
||||||
|
filterTitle = "검색 필터",
|
||||||
beforeTableContent,
|
beforeTableContent,
|
||||||
tableColumns,
|
tableColumns,
|
||||||
tableTitle,
|
tableTitle,
|
||||||
@@ -214,6 +241,75 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
|
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
|
||||||
const allSelected = selectedItems.size === data.length && data.length > 0;
|
const allSelected = selectedItems.size === data.length && data.length > 0;
|
||||||
|
|
||||||
|
// ===== filterConfig 기반 자동 필터 렌더링 =====
|
||||||
|
// PC용 인라인 필터 (xl 이상에서 표시)
|
||||||
|
const renderAutoFilters = () => {
|
||||||
|
if (!filterConfig || !filterValues || !onFilterChange) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{filterConfig.map((field) => {
|
||||||
|
if (field.type === 'single') {
|
||||||
|
// 단일선택: Select
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
key={field.key}
|
||||||
|
value={(filterValues[field.key] as string) || 'all'}
|
||||||
|
onValueChange={(value) => onFilterChange(field.key, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue placeholder={field.allOptionLabel || field.label} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{field.allOptionLabel || '전체'}
|
||||||
|
</SelectItem>
|
||||||
|
{field.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 다중선택: MultiSelectCombobox
|
||||||
|
return (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
key={field.key}
|
||||||
|
options={field.options.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
}))}
|
||||||
|
value={(filterValues[field.key] as string[]) || []}
|
||||||
|
onChange={(value) => onFilterChange(field.key, value)}
|
||||||
|
placeholder={field.label}
|
||||||
|
searchPlaceholder={`${field.label} 검색...`}
|
||||||
|
className="w-[140px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모바일용 바텀시트 필터 (xl 미만에서 표시)
|
||||||
|
const renderAutoMobileFilter = () => {
|
||||||
|
if (!filterConfig || !filterValues || !onFilterChange || !onFilterReset) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileFilter
|
||||||
|
fields={filterConfig}
|
||||||
|
values={filterValues}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
onReset={onFilterReset}
|
||||||
|
buttonLabel="필터"
|
||||||
|
title={filterTitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 일괄삭제 확인 핸들러
|
// 일괄삭제 확인 핸들러
|
||||||
const handleBulkDeleteClick = () => {
|
const handleBulkDeleteClick = () => {
|
||||||
setShowDeleteDialog(true);
|
setShowDeleteDialog(true);
|
||||||
@@ -316,7 +412,9 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
{selectedItems.size}개 항목 선택됨
|
{selectedItems.size}개 항목 선택됨
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) */}
|
{/* filterConfig 기반 자동 필터 (PC) */}
|
||||||
|
{renderAutoFilters()}
|
||||||
|
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) - 기존 방식 */}
|
||||||
{tableHeaderActions}
|
{tableHeaderActions}
|
||||||
{selectedItems.size >= 1 && onBulkDelete && (
|
{selectedItems.size >= 1 && onBulkDelete && (
|
||||||
<Button
|
<Button
|
||||||
@@ -351,6 +449,16 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */}
|
||||||
|
{(filterConfig || mobileFilterSlot) && (
|
||||||
|
<div className="xl:hidden mb-4">
|
||||||
|
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */}
|
||||||
|
{renderAutoMobileFilter()}
|
||||||
|
{/* 기존 방식: mobileFilterSlot 직접 전달 */}
|
||||||
|
{mobileFilterSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
|
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
|
||||||
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
||||||
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
|
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
|
||||||
@@ -554,4 +662,7 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 필터 관련 타입 재export (다른 페이지에서 사용 가능)
|
||||||
|
export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";
|
||||||
@@ -426,7 +426,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
||||||
>
|
>
|
||||||
<Bell className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600" />
|
<Bell className={`h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600 ${MOCK_NOTIFICATIONS.some(n => n.isNew) ? 'animate-bell-ring' : ''}`} />
|
||||||
{/* 알림 있을 때 빨간 점 */}
|
{/* 알림 있을 때 빨간 점 */}
|
||||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||||
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
||||||
@@ -627,33 +627,16 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
<span className="hidden xl:inline">품질인정심사</span>
|
<span className="hidden xl:inline">품질인정심사</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 회사 선택 셀렉트 박스 (목업) */}
|
|
||||||
<div className="hidden md:flex items-center">
|
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
||||||
<SelectTrigger className="w-40 h-10 rounded-xl border-border/50 bg-background/50 hover:bg-accent transition-all duration-200">
|
|
||||||
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
|
||||||
<SelectValue placeholder="회사 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{MOCK_COMPANIES.map((company) => (
|
|
||||||
<SelectItem key={company.id} value={company.id}>
|
|
||||||
{company.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 알림 버튼 */}
|
{/* 알림 버튼 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="p-1 rounded-xl hover:bg-accent transition-all duration-200 relative">
|
<Button variant="ghost" size="sm" className="p-1 rounded-xl hover:bg-accent transition-all duration-200 relative">
|
||||||
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
|
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
|
||||||
<Bell className="h-5 w-5 text-muted-foreground" />
|
<Bell className={`text-amber-500 ${MOCK_NOTIFICATIONS.some(n => n.isNew) ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
|
||||||
</div>
|
</div>
|
||||||
{/* 알림 있을 때 빨간 점 */}
|
{/* 알림 있을 때 빨간 점 */}
|
||||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||||
<span className="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
|
<span className="absolute top-0 right-0 w-3.5 h-3.5 bg-red-500 rounded-full border-2 border-background" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user