feat(WEB): 공사관리 시스템 및 CEO 대시보드 기능 확장

- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현
- 이슈관리: 현장 이슈 등록/조회 기능 추가
- 근로자현황: 일별 근로자 출역 현황 페이지 추가
- 유틸리티관리: 현장 유틸리티 관리 페이지 추가
- 기성청구: 기성청구 관리 페이지 추가
- CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선
- 발주관리: 모바일 필터 적용, 리스트 UI 개선
- 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-13 17:18:29 +09:00
parent d036ce4f42
commit db47a15544
85 changed files with 12940 additions and 499 deletions

View File

@@ -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 스피너 규칙 추가*

View 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

View File

@@ -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 적용 |

View File

@@ -34,6 +34,19 @@ Last Updated: 2026-01-12
| **구조검토관리** | `/ko/construction/order/structure-review` | 🆕 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) - 발주관리 하위
| 페이지 | URL | 상태 |
|---|---|---|
@@ -41,6 +54,3 @@ Last Updated: 2026-01-12
| **품목관리** | `/ko/construction/order/base-info/items` | 🆕 NEW |
| **단가관리** | `/ko/construction/order/base-info/pricing` | 🆕 NEW |
| **노임관리** | `/ko/construction/order/base-info/labor` | 🆕 NEW |
## 공사 관리 (Construction)

View File

@@ -6,6 +6,14 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = {
reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'placehold.co',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '10mb', // 이미지 업로드를 위한 제한 증가

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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} />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
export default function IssueNewPage() {
return <IssueDetailForm mode="create" />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
export default function UtilityManagementPage() {
return <UtilityManagementListClient />;
}

View File

@@ -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} />;
}

View File

@@ -284,6 +284,25 @@
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 */
::-webkit-scrollbar {
width: 8px;

View File

@@ -9,6 +9,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
TodayIssueSection,
StatusBoardSection,
DailyReportSection,
MonthlyExpenseSection,
CardManagementSection,
@@ -214,12 +215,9 @@ export function CEODashboard() {
/>
<div className="space-y-6">
{/* 오늘의 이슈 */}
{dashboardSettings.todayIssue.enabled && (
<TodayIssueSection
items={data.todayIssue}
itemSettings={dashboardSettings.todayIssue.items}
/>
{/* 오늘의 이슈 (새 리스트 형태) */}
{dashboardSettings.todayIssueList && (
<TodayIssueSection items={data.todayIssueList} />
)}
{/* 일일 일보 */}
@@ -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 && (
<MonthlyExpenseSection

View File

@@ -35,8 +35,8 @@ import type {
} 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: '수주',
debtCollection: '채권 추심',
safetyStock: '안전 재고',
@@ -83,37 +83,67 @@ export function DashboardSettingsDialog({
}));
}, []);
// 오늘의 이슈 전체 토글
const handleTodayIssueToggle = useCallback((enabled: boolean) => {
// 오늘의 이슈 (리스트 형태) 토글
const handleTodayIssueListToggle = useCallback((enabled: boolean) => {
setLocalSettings((prev) => ({
...prev,
todayIssue: {
...prev.todayIssue,
enabled,
// 전체 OFF 시 개별 항목도 모두 OFF
items: enabled
? prev.todayIssue.items
: Object.keys(prev.todayIssue.items).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{} as TodayIssueSettings
),
},
todayIssueList: enabled,
}));
}, []);
// 오늘의 이슈 개별 항목 토글
const handleTodayIssueItemToggle = useCallback(
(key: keyof TodayIssueSettings, enabled: boolean) => {
setLocalSettings((prev) => ({
// 현황판 전체 토글 (구 오늘의 이슈)
const handleStatusBoardToggle = useCallback((enabled: boolean) => {
setLocalSettings((prev) => {
const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items;
return {
...prev,
todayIssue: {
...prev.todayIssue,
items: {
...prev.todayIssue.items,
[key]: enabled,
},
statusBoard: {
enabled,
// 전체 OFF 시 개별 항목도 모두 OFF
items: 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>
<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="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
checked={localSettings.todayIssue.enabled}
onCheckedChange={handleTodayIssueToggle}
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
onCheckedChange={handleStatusBoardToggle}
/>
</div>
{localSettings.todayIssue.enabled && (
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
<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) => (
<div
key={key}
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
>
<span className="text-sm text-gray-600">
{TODAY_ISSUE_LABELS[key]}
{STATUS_BOARD_LABELS[key]}
</span>
<ToggleSwitch
checked={localSettings.todayIssue.items[key]}
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
onCheckedChange={(checked) =>
handleTodayIssueItemToggle(key, checked)
handleStatusBoardItemToggle(key, checked)
}
/>
</div>
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({
)}
</div>
{/* 일일 일보 */}
<SectionRow
label="일일 일보"
checked={localSettings.dailyReport}
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
/>
{/* 당월 예상 지출 내역 */}
<SectionRow
label="당월 예상 지출 내역"

View File

@@ -15,6 +15,128 @@ export const mockData: CEODashboardData = {
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', 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: {
date: '2026년 1월 5일 월요일',
cards: [

View File

@@ -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>
);
}

View File

@@ -1,71 +1,147 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, IssueCardItem } from '../components';
import type { TodayIssueItem, TodayIssueSettings } from '../types';
import { Button } from '@/components/ui/button';
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> = {
'수주': 'orders',
'채권 추심': 'debtCollection',
'안전 재고': 'safetyStock',
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'발주': 'purchase',
'결재 요청': 'approvalRequest',
// 뱃지 색상 매핑
const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주 성공': 'bg-blue-100 text-blue-700 hover:bg-blue-100',
'주식 이슈': 'bg-purple-100 text-purple-700 hover:bg-purple-100',
'직정 제고': 'bg-orange-100 text-orange-700 hover:bg-orange-100',
'지출예상내역서': 'bg-green-100 text-green-700 hover:bg-green-100',
'세금 신고': 'bg-red-100 text-red-700 hover:bg-red-100',
'결재 요청': 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100',
'기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100',
};
// 필터 옵션
const FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '수주 성공', label: '수주 성공' },
{ value: '주식 이슈', label: '주식 이슈' },
{ value: '직정 제고', label: '직정 제고' },
{ value: '지출예상내역서', label: '지출예상내역서' },
{ value: '세금 신고', label: '세금 신고' },
{ value: '결재 요청', label: '결재 요청' },
];
interface TodayIssueSectionProps {
items: TodayIssueItem[];
itemSettings?: TodayIssueSettings;
items: TodayIssueListItem[];
}
export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProps) {
export function TodayIssueSection({ items }: TodayIssueSectionProps) {
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
? items.filter((item) => {
const settingKey = LABEL_TO_SETTING_KEY[item.label];
return settingKey ? itemSettings[settingKey] : true;
})
: items;
// 승인 버튼 클릭
const handleApprove = (item: TodayIssueListItem) => {
toast.success(`"${item.content}" 승인 처리되었습니다.`);
};
// 아이템 개수에 따른 동적 그리드 클래스 (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';
// 반려 버튼 클릭
const handleReject = (item: TodayIssueListItem) => {
toast.error(`"${item.content}" 반려 처리되었습니다.`);
};
return (
<Card>
<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) => (
<IssueCardItem
key={item.id}
label={item.label}
count={item.count}
subLabel={item.subLabel}
isHighlighted={item.isHighlighted}
onClick={() => handleItemClick(item.path)}
icon={item.icon}
/>
))}
{/* 리스트 */}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1">
{filteredItems.length === 0 ? (
<div className="text-center py-8 text-gray-500">
.
</div>
) : (
filteredItems.map((item) => (
<div
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>
</CardContent>
</Card>

View File

@@ -1,4 +1,5 @@
export { TodayIssueSection } from './TodayIssueSection';
export { StatusBoardSection } from './StatusBoardSection';
export { DailyReportSection } from './DailyReportSection';
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
export { CardManagementSection } from './CardManagementSection';

View File

@@ -45,7 +45,7 @@ export interface AmountCard {
isHighlighted?: boolean; // 빨간색 강조
}
// 오늘의 이슈 항목
// 오늘의 이슈 항목 (카드 형태 - 현황판용)
export interface TodayIssueItem {
id: string;
label: string;
@@ -56,6 +56,26 @@ export interface TodayIssueItem {
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 {
date: string; // "2026년 1월 5일 월요일"
@@ -135,7 +155,8 @@ export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'constructio
// CEO Dashboard 전체 데이터
export interface CEODashboardData {
todayIssue: TodayIssueItem[];
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
dailyReport: DailyReportData;
monthlyExpense: MonthlyExpenseData;
cardManagement: CardManagementData;
@@ -194,8 +215,10 @@ export interface WelfareSettings {
// 대시보드 전체 설정
export interface DashboardSettings {
// 오늘의 이슈 섹션
todayIssue: {
// 오늘의 이슈 섹션 (새 리스트 형태)
todayIssueList: boolean;
// 현황판 섹션 (구 오늘의 이슈 - 카드 형태)
statusBoard: {
enabled: boolean;
items: TodayIssueSettings;
};
@@ -212,6 +235,11 @@ export interface DashboardSettings {
debtCollection: boolean;
vat: 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 = {
todayIssue: {
// 새 오늘의 이슈 (리스트 형태)
todayIssueList: true,
// 현황판 (구 오늘의 이슈 - 카드 형태)
statusBoard: {
enabled: true,
items: {
orders: true,
@@ -436,4 +467,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
debtCollection: true,
vat: 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,
},
},
};

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View 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: '이슈 일괄 철회에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,4 @@
export { default as IssueManagementListClient } from './IssueManagementListClient';
export { default as IssueDetailForm } from './IssueDetailForm';
export * from './types';
export * from './actions';

View 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: '기타' },
];

View File

@@ -512,59 +512,58 @@ export default function ItemDetailClient({
/>
</div>
</div>
</CardContent>
</Card>
{/* 발주 항목 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{!isReadOnly && (
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent>
{formData.orderItems.length === 0 ? (
<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>
{/* 발주 항목 구분정보 */}
{/* 수정 모드: 항상 표시 (추가/삭제 가능) */}
{/* 상세 모드: 데이터가 있을 때만 표시 (읽기 전용) */}
{(!isReadOnly || formData.orderItems.length > 0) && (
<div className="pt-4">
{/* 헤더 */}
<div className="grid grid-cols-[1fr_1fr_auto] gap-4 items-center mb-4">
<div className="text-base font-semibold"> </div>
<div className="text-base font-semibold"> </div>
{!isReadOnly && (
<Button size="sm" onClick={handleAddOrderItem}>
</Button>
)}
</div>
{formData.orderItems.map((item) => (
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 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="ghost"
size="icon"
className="h-9 w-9"
onClick={() => handleRemoveOrderItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
)}
{/* 항목 리스트 */}
{formData.orderItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
. .
</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>
)}
</CardContent>

View File

@@ -62,6 +62,10 @@ export default function LaborDetailClient({
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
const [originalData, setOriginalData] = useState<Labor | null>(null);
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
const [minMInput, setMinMInput] = useState<string>('');
const [maxMInput, setMaxMInput] = useState<string>('');
// 상태
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -84,6 +88,9 @@ export default function LaborDetailClient({
laborPrice: result.data.laborPrice,
status: result.data.status,
});
// 소수점 입력용 문자열 상태 초기화
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
@@ -107,19 +114,62 @@ export default function LaborDetailClient({
[]
);
// 숫자 입력 (소수점 둘째자리까지)
const handleNumberChange = useCallback(
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
const handleMinMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
handleFieldChange(field, field === 'laborPrice' ? null : 0);
setMinMInput('');
handleFieldChange('minM', 0);
return;
}
// 소수점 둘째자리까지 허용
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMinMInput(value);
const numValue = parseFloat(value);
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,
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}`);
}
@@ -339,8 +392,8 @@ export default function LaborDetailClient({
id="minM"
type="text"
inputMode="decimal"
value={formData.minM === 0 ? '' : formData.minM.toString()}
onChange={(e) => handleNumberChange('minM', e.target.value)}
value={minMInput}
onChange={(e) => handleMinMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
@@ -351,8 +404,8 @@ export default function LaborDetailClient({
id="maxM"
type="text"
inputMode="decimal"
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
onChange={(e) => handleNumberChange('maxM', e.target.value)}
value={maxMInput}
onChange={(e) => handleMaxMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
@@ -366,9 +419,9 @@ export default function LaborDetailClient({
<Input
id="laborPrice"
type="text"
inputMode="decimal"
inputMode="numeric"
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
onChange={(e) => handleLaborPriceChange(e.target.value)}
placeholder="0"
disabled={isReadOnly}
/>

View File

@@ -3,6 +3,8 @@
// 구분 옵션
export const CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '작업반장', label: '작업반장' },
{ value: '작업자', label: '작업자' },
{ value: '가로', label: '가로' },
{ value: '세로할증', label: '세로할증' },
] as const;

View File

@@ -1,7 +1,7 @@
// 노임관리 타입 정의
// 구분 타입
export type LaborCategory = '가로' | '세로할증';
export type LaborCategory = '작업반장' | '작업자' | '가로' | '세로할증';
// 상태 타입
export type LaborStatus = '사용' | '중지';

View File

@@ -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>
);
}

View File

@@ -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>
}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,6 +1,19 @@
'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
@@ -426,4 +439,785 @@ export async function updateProject(
console.error('updateProject error:', 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: '시공 완료 처리에 실패했습니다.' };
}
}

View File

@@ -95,4 +95,366 @@ export const GANTT_BAR_COLORS = {
completed: '#9CA3AF', // 회색 - 종료
in_progress: '#3B82F6', // 파란색 - 진행중
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: '오차장' },
];

View File

@@ -8,6 +8,7 @@ import type { OrderDetail } from './types';
import { useOrderDetailForm } from './hooks/useOrderDetailForm';
import { OrderInfoCard } from './cards/OrderInfoCard';
import { ContractInfoCard } from './cards/ContractInfoCard';
import { ConstructionDetailCard } from './cards/ConstructionDetailCard';
import { OrderScheduleCard } from './cards/OrderScheduleCard';
import { OrderMemoCard } from './cards/OrderMemoCard';
import { OrderDetailItemTable } from './tables/OrderDetailItemTable';
@@ -159,6 +160,13 @@ export default function OrderDetailForm({
onFieldChange={handleFieldChange}
/>
{/* 시공 상세 */}
<ConstructionDetailCard
formData={formData}
isViewMode={isViewMode}
onFieldChange={handleFieldChange}
/>
{/* 발주 스케줄 (달력) */}
<OrderScheduleCard
events={calendarEvents}

View File

@@ -14,7 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
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 { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
@@ -70,9 +70,8 @@ const tableColumns: TableColumn[] = [
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'item', label: '품목', className: 'w-[80px]' },
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
{ key: 'plannedDeliveryDate', label: '계획납품일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제납품일', className: 'w-[90px]' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' }, { key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
@@ -568,41 +567,41 @@ export default function OrderManagementListClient({
/>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 발주',
value: stats?.total ?? 0,
icon: Package,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '발주대기',
value: stats?.waiting ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '발주완료',
value: stats?.orderComplete ?? 0,
icon: AlertCircle,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('order_complete'),
isActive: activeStatTab === 'order_complete',
},
{
label: '납품완료',
value: stats?.deliveryComplete ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('delivery_complete'),
isActive: activeStatTab === 'delivery_complete',
},
];
// Stats 카드 데이터 - 기획서에 없어서 주석 처리
// const statsCardsData: StatCard[] = [
// {
// label: '전체 발주',
// value: stats?.total ?? 0,
// icon: Package,
// iconColor: 'text-blue-600',
// onClick: () => setActiveStatTab('all'),
// isActive: activeStatTab === 'all',
// },
// {
// label: '발주대기',
// value: stats?.waiting ?? 0,
// icon: Clock,
// iconColor: 'text-yellow-600',
// onClick: () => setActiveStatTab('waiting'),
// isActive: activeStatTab === 'waiting',
// },
// {
// label: '발주완료',
// value: stats?.orderComplete ?? 0,
// icon: AlertCircle,
// iconColor: 'text-blue-600',
// onClick: () => setActiveStatTab('order_complete'),
// isActive: activeStatTab === 'order_complete',
// },
// {
// label: '납품완료',
// value: stats?.deliveryComplete ?? 0,
// icon: CheckCircle,
// iconColor: 'text-green-600',
// onClick: () => setActiveStatTab('delivery_complete'),
// isActive: activeStatTab === 'delivery_complete',
// },
// ];
// 필터 옵션들
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
@@ -611,8 +610,82 @@ export default function OrderManagementListClient({
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
// 테이블 헤더 액션 (기획서 요구사항)
// 거래처, 현장명, 공사PM, 발주담당자, 발주처, 작업반장, 구분, 상태, 최신순
// ===== filterConfig 기반 통합 필터 시스템 =====
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 = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총건 표시 */}
@@ -625,104 +698,6 @@ export default function OrderManagementListClient({
)}
</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 && (
<Button
@@ -767,7 +742,13 @@ export default function OrderManagementListClient({
description="발주 스케줄 및 목록을 관리합니다"
icon={Package}
headerActions={headerActions}
stats={statsCardsData}
// stats={statsCardsData} // 기획서에 없어서 주석 처리
// 통합 필터 시스템 - PC는 인라인, 모바일은 바텀시트 자동 분기
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="발주 필터"
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}

View File

@@ -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>
);
}

View File

@@ -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="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.partnerId}
onValueChange={(value) => {
@@ -67,7 +67,7 @@ export function ContractInfoCard({
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.siteName}
onChange={(e) => onFieldChange('siteName', e.target.value)}
@@ -77,7 +77,7 @@ export function ContractInfoCard({
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.contractNumber}
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
@@ -87,7 +87,7 @@ export function ContractInfoCard({
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Label>PM<span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.constructionPMId}
onValueChange={(value) => {
@@ -114,7 +114,7 @@ export function ContractInfoCard({
{/* 공사담당자 */}
<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">
{formData.constructionManagers.map((manager, index) => (
<div key={index} className="flex items-center gap-1">

View File

@@ -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="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.orderNumber}
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
@@ -44,7 +44,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 발주일 (발주처) */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderCompanyId}
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
@@ -65,7 +65,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 구분 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderType}
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
@@ -86,7 +86,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.status}
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
@@ -107,7 +107,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 발주담당자 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderManager}
onValueChange={(value) => onFieldChange('orderManager', value)}
@@ -128,7 +128,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 화물도착지 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.deliveryAddress}
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}

View File

@@ -125,7 +125,7 @@ export function OrderDocumentModal({
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
<tbody>
{/* 출고일 / 작업 */}
{/* 출고일 / 작업반장 */}
<tr>
<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">
{formatDate(order.plannedDeliveryDate)}
</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>
<td className="border border-gray-300 px-4 py-3">
{order.workTeamLeader || '-'}
</td>
</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">{order.siteName || '-'}</td>
<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>
{/* 화물 도착지 / 발주담당자 */}
<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>
<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>
@@ -166,6 +166,14 @@ export function OrderDocumentModal({
{order.orderManager || '-'}
</td>
</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>
</table>

View File

@@ -166,8 +166,8 @@ export function OrderDetailItemTable({
<TableHead className="w-[100px]"></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-[100px]"></TableHead>
</TableRow>
</TableHeader>

View File

@@ -48,9 +48,9 @@ export interface Order {
quantity: number;
/** 발주일 */
orderDate: string;
/** 계획납품일 */
/** 계획인수일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
/** 실제 인수일 */
actualDeliveryDate: string | null;
/** 상태 */
status: OrderStatus;
@@ -74,9 +74,9 @@ export interface OrderStats {
waiting: number;
/** 발주완료 */
orderComplete: number;
/** 납품예정 */
/** 인수예정 */
deliveryScheduled: number;
/** 납품완료 */
/** 인수완료 */
deliveryComplete: number;
}
@@ -87,8 +87,8 @@ export const ORDER_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'waiting', label: '발주대기' },
{ value: 'order_complete', label: '발주완료' },
{ value: 'delivery_scheduled', label: '납품예정' },
{ value: 'delivery_complete', label: '납품완료' },
{ value: 'delivery_scheduled', label: '인수예정' },
{ value: 'delivery_complete', label: '인수완료' },
] as const;
/**
@@ -97,8 +97,8 @@ export const ORDER_STATUS_OPTIONS = [
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
waiting: '발주대기',
order_complete: '발주완료',
delivery_scheduled: '납품예정',
delivery_complete: '납품완료',
delivery_scheduled: '인수예정',
delivery_complete: '인수완료',
};
/**
@@ -210,8 +210,8 @@ export const ORDER_SORT_OPTIONS = [
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
{ value: 'siteNameAsc', label: '현장명 ↑' },
{ value: 'siteNameDesc', label: '현장명 ↓' },
{ value: 'deliveryDateAsc', label: '납품일 ↑' },
{ value: 'deliveryDateDesc', label: '납품일 ↓' },
{ value: 'deliveryDateAsc', label: '인수일 ↑' },
{ value: 'deliveryDateDesc', label: '인수일 ↓' },
] as const;
/**
@@ -322,9 +322,9 @@ export interface OrderDetailItem {
imageUrl: string;
/** 발주일 */
orderDate: string;
/** 계획 납품일 */
/** 계획 인수일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
/** 실제 인수일 */
actualDeliveryDate: string;
/** 상태 */
status: OrderStatus;
@@ -412,6 +412,12 @@ export interface OrderDetailFormData {
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 (필수) */
constructionStartDate: string;
/** 시공완료일 */
constructionEndDate: string;
/** 발주 상세 카테고리 목록 */
orderCategories: OrderDetailCategory[];
/** 비고 */
@@ -518,6 +524,9 @@ export function getEmptyOrderDetailFormData(): OrderDetailFormData {
constructionPMId: '',
constructionPM: '',
constructionManagers: [],
workTeamLeader: '',
constructionStartDate: '',
constructionEndDate: '',
orderCategories: [],
memo: '',
periodStart: '',
@@ -563,6 +572,9 @@ export function orderDetailToFormData(detail: OrderDetail): OrderDetailFormData
constructionPMId: detail.constructionPMId,
constructionPM: detail.constructionPM,
constructionManagers: detail.constructionManagers,
workTeamLeader: detail.workTeamLeader,
constructionStartDate: detail.constructionStartDate,
constructionEndDate: '', // Order 인터페이스에는 없으므로 빈 값
orderCategories: Array.from(categoryMap.values()),
memo: detail.memo,
periodStart: detail.periodStart,

View File

@@ -14,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} 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 { toast } from 'sonner';
import {
@@ -375,6 +375,54 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
[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 = (
<div className="flex items-center justify-end w-full">
@@ -445,6 +493,11 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
activeTab={activeTab}
onTabChange={handleTabChange}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="거래처 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -51,8 +51,6 @@ interface FormData {
unit: string;
division: string;
vendor: string;
purchasePrice: number;
marginRate: number;
sellingPrice: number;
status: PricingStatus;
note: string;
@@ -66,8 +64,6 @@ const initialFormData: FormData = {
unit: '',
division: '',
vendor: '',
purchasePrice: 0,
marginRate: 0,
sellingPrice: 0,
status: 'in_use',
note: '',
@@ -109,8 +105,6 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
unit: result.data.unit,
division: result.data.division,
vendor: result.data.vendor,
purchasePrice: result.data.purchasePrice,
marginRate: result.data.marginRate,
sellingPrice: result.data.sellingPrice,
status: result.data.status,
note: '',
@@ -130,29 +124,12 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
loadData();
}, [id, mode, isViewMode, isEditMode, router]);
// 판매단가 자동 계산: 매입단가 * (1 + 마진율/100)
const calculatedSellingPrice = useMemo(() => {
const price = formData.purchasePrice * (1 + formData.marginRate / 100);
return Math.round(price);
}, [formData.purchasePrice, formData.marginRate]);
// 매입단가 변경
const handlePurchasePriceChange = useCallback((value: string) => {
// 판매단가 변경
const handleSellingPriceChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
purchasePrice: 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)),
sellingPrice: numValue,
}));
}, []);
@@ -185,9 +162,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
unit: formData.unit,
division: formData.division,
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
@@ -200,9 +175,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
} else if (isEditMode && id) {
const result = await updatePricing(id, {
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
@@ -218,7 +191,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
} finally {
setIsLoading(false);
}
}, [isCreateMode, isEditMode, id, formData, calculatedSellingPrice, router]);
}, [isCreateMode, isEditMode, id, formData, router]);
// 삭제
const handleDelete = useCallback(async () => {
@@ -379,7 +352,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
</div>
</div>
{/* 거래처 / 매단가 */}
{/* 거래처 / 매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
@@ -400,48 +373,17 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
</Select>
)}
</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">
<Label></Label>
<Input
value={formatNumber(isViewMode ? formData.sellingPrice : calculatedSellingPrice)}
disabled
className="bg-muted"
/>
{!isViewMode && (
<p className="text-xs text-muted-foreground">
× (1 + ) =
</p>
{isViewMode ? (
<Input value={formatNumber(formData.sellingPrice)} disabled />
) : (
<Input
type="number"
value={formData.sellingPrice}
onChange={(e) => handleSellingPriceChange(e.target.value)}
placeholder="판매단가 입력"
/>
)}
</div>
</div>

View File

@@ -325,8 +325,6 @@ export default function PricingListClient({
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[60px]"></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-[70px] text-center"></TableHead>
{selectedItems.size > 0 && (
@@ -368,8 +366,6 @@ export default function PricingListClient({
<TableCell>{pricing.unit}</TableCell>
<TableCell>{pricing.division}</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-center">
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>

View File

@@ -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>
);
}

View File

@@ -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,
}}
/>
);
}

View 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: '상태 변경에 실패했습니다.',
};
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,3 @@
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
export * from './types';
export * from './actions';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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',
};

View File

@@ -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>
</>
);
}

View File

@@ -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: '공과 상세 조회에 실패했습니다.',
};
}
}

View File

@@ -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';

View 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: '최반장' },
];

View File

@@ -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,
}}
/>
);
}

View 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: '통계 정보를 불러오는데 실패했습니다.',
};
}
}

View 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: '최민수' },
];

View File

@@ -27,61 +27,97 @@ export function CalendarHeader({
];
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
{/* 좌측: 타이틀 + 년월 네비게이션 */}
<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>
<div className="flex flex-col gap-3 pb-3 border-b">
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
{/* 모바일: 타이틀 / 네비게이션 + 뷰전환 / 필터 (세 줄) */}
<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>
{/* 1줄(모바일) / 좌측(PC): 타이틀 */}
{titleSlot && (
<div className="xl:hidden text-base font-semibold text-foreground">
{titleSlot}
</div>
</div>
)}
{/* 우측: 뷰 전환 + 필터 */}
<div className="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'
)}
{/* 2줄(모바일) / 전체(PC): 네비게이션 + 뷰전환 + 필터 */}
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
{/* 좌측: (PC에서만 타이틀) + 네비게이션 */}
<div className="flex items-center gap-4">
{titleSlot && (
<span className="hidden xl:block 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}
>
{v.label}
</button>
))}
<ChevronLeft className="h-4 w-4" />
</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>
{/* 필터 슬롯 */}
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
{/* 우측(PC만): 뷰 전환 + 필터 */}
<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>
{/* 3줄(모바일만): 필터 */}
{filterSlot && (
<div className="flex xl:hidden items-center gap-2">{filterSlot}</div>
)}
</div>
);
}

View File

@@ -121,8 +121,9 @@ export function DateRangeSelector({
return (
<div className="flex flex-col gap-2 w-full">
{/* 상단: 날짜 선택 + 기간 버튼 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
{/* 1줄: 날짜 + 프리셋 */}
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
{/* 날짜 범위 선택 (Input type="date") */}
{!hideDateInputs && (
<div className="flex items-center gap-1 shrink-0">
@@ -145,7 +146,7 @@ export function DateRangeSelector({
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
{!hidePresets && presets.length > 0 && (
<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' }}
>
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
@@ -165,9 +166,9 @@ export function DateRangeSelector({
)}
</div>
{/* 하단: 추가 액션 버튼들 */}
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
{extraActions && (
<div className="flex items-center gap-2 flex-wrap sm:justify-end">
<div className="flex items-center gap-2 justify-end">
{extraActions}
</div>
)}

View File

@@ -17,6 +17,7 @@ interface MobileCardProps {
description?: string;
badge?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
badgeClassName?: string;
isSelected?: boolean;
onToggle?: () => void;
onClick?: () => void;
@@ -31,6 +32,7 @@ export function MobileCard({
description,
badge,
badgeVariant = 'default',
badgeClassName,
isSelected = false,
onToggle,
onClick,
@@ -63,7 +65,7 @@ export function MobileCard({
<div className="text-sm text-muted-foreground">{subtitle}</div>
)}
</div>
{badge && <Badge variant={badgeVariant}>{badge}</Badge>}
{badge && <Badge variant={badgeVariant} className={badgeClassName}>{badge}</Badge>}
</div>
{/* 설명 */}

View 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>
);
}

View File

@@ -7,6 +7,13 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
@@ -23,6 +30,8 @@ import { StatCards } from "@/components/organisms/StatCards";
import { SearchFilter } from "@/components/organisms/SearchFilter";
import { ScreenVersionHistory } from "@/components/organisms/ScreenVersionHistory";
import { TabChip } from "@/components/atoms/TabChip";
import { MultiSelectCombobox } from "@/components/ui/multi-select-combobox";
import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";
/**
* 기본 통합 목록_버젼2
@@ -119,6 +128,18 @@ export interface IntegratedListTemplateV2Props<T = any> {
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
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;
@@ -184,6 +205,12 @@ export function IntegratedListTemplateV2<T = any>({
activeTab,
onTabChange,
tableHeaderActions,
mobileFilterSlot,
filterConfig,
filterValues,
onFilterChange,
onFilterReset,
filterTitle = "검색 필터",
beforeTableContent,
tableColumns,
tableTitle,
@@ -214,6 +241,75 @@ export function IntegratedListTemplateV2<T = any>({
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
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 = () => {
setShowDeleteDialog(true);
@@ -316,7 +412,9 @@ export function IntegratedListTemplateV2<T = any>({
{selectedItems.size}
</span>
)}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) */}
{/* filterConfig 기반 자동 필터 (PC) */}
{renderAutoFilters()}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) - 기존 방식 */}
{tableHeaderActions}
{selectedItems.size >= 1 && onBulkDelete && (
<Button
@@ -351,6 +449,16 @@ export function IntegratedListTemplateV2<T = any>({
</div>
)}
{/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */}
{(filterConfig || mobileFilterSlot) && (
<div className="xl:hidden mb-4">
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */}
{renderAutoMobileFilter()}
{/* 기존 방식: mobileFilterSlot 직접 전달 */}
{mobileFilterSlot}
</div>
)}
{/* 모바일/태블릿/소형 노트북 (~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">
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
@@ -554,4 +662,7 @@ export function IntegratedListTemplateV2<T = any>({
</AlertDialog>
</PageLayout>
);
}
}
// 필터 관련 타입 재export (다른 페이지에서 사용 가능)
export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";

View File

@@ -426,7 +426,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
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"
>
<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) && (
<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>
</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>
<DropdownMenuTrigger asChild>
<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">
<Bell className="h-5 w-5 text-muted-foreground" />
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
<Bell className={`text-amber-500 ${MOCK_NOTIFICATIONS.some(n => n.isNew) ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
</div>
{/* 알림 있을 때 빨간 점 */}
{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>
</DropdownMenuTrigger>

File diff suppressed because one or more lines are too long