From db47a15544c304faea43350db67ea673837e88bf Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Tue, 13 Jan 2026 17:18:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B3=B5=EC=82=AC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20CEO=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 --- .../[GUIDE] collaboration-with-claude.md | 35 +- ...PL-2026-01-12] project-detail-checklist.md | 52 ++ ...1-13] mobile-filter-migration-checklist.md | 180 ++++ .../[REF] construction-pages-test-urls.md | 16 +- next.config.ts | 8 + .../[id]/edit/page.tsx | 57 ++ .../progress-billing-management/[id]/page.tsx | 57 ++ .../progress-billing-management/page.tsx | 38 + .../[id]/edit/page.tsx | 16 + .../construction-management/[id]/page.tsx | 16 + .../project/construction-management/page.tsx | 52 ++ .../issue-management/[id]/edit/page.tsx | 53 ++ .../project/issue-management/[id]/page.tsx | 53 ++ .../project/issue-management/new/page.tsx | 7 + .../project/issue-management/page.tsx | 52 ++ .../project/management/[id]/page.tsx | 17 + .../project/utility-management/page.tsx | 5 + .../project/worker-status/page.tsx | 32 + src/app/globals.css | 19 + .../business/CEODashboard/CEODashboard.tsx | 18 +- .../dialogs/DashboardSettingsDialog.tsx | 121 ++- .../business/CEODashboard/mockData.ts | 122 +++ .../sections/StatusBoardSection.tsx | 73 ++ .../sections/TodayIssueSection.tsx | 170 ++-- .../business/CEODashboard/sections/index.ts | 1 + src/components/business/CEODashboard/types.ts | 57 +- .../issue-management/IssueDetailForm.tsx | 693 +++++++++++++++ .../IssueManagementListClient.tsx | 647 ++++++++++++++ .../construction/issue-management/actions.ts | 417 +++++++++ .../construction/issue-management/index.ts | 4 + .../construction/issue-management/types.ts | 237 ++++++ .../item-management/ItemDetailClient.tsx | 99 ++- .../labor-management/LaborDetailClient.tsx | 77 +- .../labor-management/constants.ts | 2 + .../construction/labor-management/types.ts | 2 +- .../management/ConstructionDetailClient.tsx | 773 +++++++++++++++++ .../ConstructionManagementListClient.tsx | 716 ++++++++++++++++ .../management/DetailAccordion.tsx | 198 +++++ .../construction/management/DetailCard.tsx | 68 ++ .../construction/management/KanbanColumn.tsx | 51 ++ .../construction/management/ProjectCard.tsx | 85 ++ .../management/ProjectDetailClient.tsx | 196 +++++ .../management/ProjectEndDialog.tsx | 194 +++++ .../management/ProjectKanbanBoard.tsx | 244 ++++++ .../construction/management/StageCard.tsx | 84 ++ .../construction/management/actions.ts | 796 +++++++++++++++++- .../business/construction/management/types.ts | 364 +++++++- .../order-management/OrderDetailForm.tsx | 8 + .../OrderManagementListClient.tsx | 261 +++--- .../cards/ConstructionDetailCard.tsx | 85 ++ .../cards/ContractInfoCard.tsx | 10 +- .../order-management/cards/OrderInfoCard.tsx | 12 +- .../modals/OrderDocumentModal.tsx | 22 +- .../tables/OrderDetailItemTable.tsx | 4 +- .../construction/order-management/types.ts | 36 +- .../partners/PartnerListClient.tsx | 55 +- .../PricingDetailClient.tsx | 92 +- .../pricing-management/PricingListClient.tsx | 4 - .../ProgressBillingDetailForm.tsx | 246 ++++++ .../ProgressBillingManagementListClient.tsx | 440 ++++++++++ .../construction/progress-billing/actions.ts | 317 +++++++ .../cards/ContractInfoCard.tsx | 57 ++ .../cards/ProgressBillingInfoCard.tsx | 77 ++ .../hooks/useProgressBillingDetailForm.ts | 273 ++++++ .../construction/progress-billing/index.ts | 3 + .../modals/DirectConstructionModal.tsx | 268 ++++++ .../modals/IndirectConstructionModal.tsx | 382 +++++++++ .../modals/PhotoDocumentModal.tsx | 210 +++++ .../progress-billing/tables/PhotoTable.tsx | 147 ++++ .../tables/ProgressBillingItemTable.tsx | 188 +++++ .../construction/progress-billing/types.ts | 483 +++++++++++ .../UtilityManagementListClient.tsx | 592 +++++++++++++ .../utility-management/actions.ts | 286 +++++++ .../construction/utility-management/index.ts | 32 + .../construction/utility-management/types.ts | 209 +++++ .../worker-status/WorkerStatusListClient.tsx | 515 +++++++++++ .../construction/worker-status/actions.ts | 111 +++ .../construction/worker-status/types.ts | 116 +++ .../ScheduleCalendar/CalendarHeader.tsx | 132 +-- .../molecules/DateRangeSelector.tsx | 11 +- src/components/molecules/MobileCard.tsx | 4 +- src/components/molecules/MobileFilter.tsx | 335 ++++++++ .../templates/IntegratedListTemplateV2.tsx | 115 ++- src/layouts/AuthenticatedLayout.tsx | 25 +- tsconfig.tsbuildinfo | 2 +- 85 files changed, 12940 insertions(+), 499 deletions(-) create mode 100644 claudedocs/[IMPL-2026-01-12] project-detail-checklist.md create mode 100644 claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md create mode 100644 src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/construction-management/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/issue-management/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/utility-management/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/project/worker-status/page.tsx create mode 100644 src/components/business/CEODashboard/sections/StatusBoardSection.tsx create mode 100644 src/components/business/construction/issue-management/IssueDetailForm.tsx create mode 100644 src/components/business/construction/issue-management/IssueManagementListClient.tsx create mode 100644 src/components/business/construction/issue-management/actions.ts create mode 100644 src/components/business/construction/issue-management/index.ts create mode 100644 src/components/business/construction/issue-management/types.ts create mode 100644 src/components/business/construction/management/ConstructionDetailClient.tsx create mode 100644 src/components/business/construction/management/ConstructionManagementListClient.tsx create mode 100644 src/components/business/construction/management/DetailAccordion.tsx create mode 100644 src/components/business/construction/management/DetailCard.tsx create mode 100644 src/components/business/construction/management/KanbanColumn.tsx create mode 100644 src/components/business/construction/management/ProjectCard.tsx create mode 100644 src/components/business/construction/management/ProjectDetailClient.tsx create mode 100644 src/components/business/construction/management/ProjectEndDialog.tsx create mode 100644 src/components/business/construction/management/ProjectKanbanBoard.tsx create mode 100644 src/components/business/construction/management/StageCard.tsx create mode 100644 src/components/business/construction/order-management/cards/ConstructionDetailCard.tsx create mode 100644 src/components/business/construction/progress-billing/ProgressBillingDetailForm.tsx create mode 100644 src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx create mode 100644 src/components/business/construction/progress-billing/actions.ts create mode 100644 src/components/business/construction/progress-billing/cards/ContractInfoCard.tsx create mode 100644 src/components/business/construction/progress-billing/cards/ProgressBillingInfoCard.tsx create mode 100644 src/components/business/construction/progress-billing/hooks/useProgressBillingDetailForm.ts create mode 100644 src/components/business/construction/progress-billing/index.ts create mode 100644 src/components/business/construction/progress-billing/modals/DirectConstructionModal.tsx create mode 100644 src/components/business/construction/progress-billing/modals/IndirectConstructionModal.tsx create mode 100644 src/components/business/construction/progress-billing/modals/PhotoDocumentModal.tsx create mode 100644 src/components/business/construction/progress-billing/tables/PhotoTable.tsx create mode 100644 src/components/business/construction/progress-billing/tables/ProgressBillingItemTable.tsx create mode 100644 src/components/business/construction/progress-billing/types.ts create mode 100644 src/components/business/construction/utility-management/UtilityManagementListClient.tsx create mode 100644 src/components/business/construction/utility-management/actions.ts create mode 100644 src/components/business/construction/utility-management/index.ts create mode 100644 src/components/business/construction/utility-management/types.ts create mode 100644 src/components/business/construction/worker-status/WorkerStatusListClient.tsx create mode 100644 src/components/business/construction/worker-status/actions.ts create mode 100644 src/components/business/construction/worker-status/types.ts create mode 100644 src/components/molecules/MobileFilter.tsx diff --git a/claudedocs/[GUIDE] collaboration-with-claude.md b/claudedocs/[GUIDE] collaboration-with-claude.md index fe634187..6f85bccc 100644 --- a/claudedocs/[GUIDE] collaboration-with-claude.md +++ b/claudedocs/[GUIDE] collaboration-with-claude.md @@ -93,4 +93,37 @@ --- -*2025-11-27 작성* +## 공통 UI 컴포넌트 사용 규칙 + +### 로딩 스피너 + +**필수**: 로딩 상태 표시 시 반드시 공통 스피너 컴포넌트 사용 + +```tsx +import { + ContentLoadingSpinner, + PageLoadingSpinner, + TableLoadingSpinner, + ButtonSpinner +} from '@/components/ui/loading-spinner'; +``` + +| 컴포넌트 | 용도 | 예시 | +|----------|------|------| +| `ContentLoadingSpinner` | 상세/수정 페이지 컨텐츠 영역 | `if (isLoading) return ;` | +| `PageLoadingSpinner` | 페이지 전환, 전체 페이지 | loading.tsx, 초기 로딩 | +| `TableLoadingSpinner` | 테이블/리스트 영역 | 데이터 테이블 로딩 | +| `ButtonSpinner` | 버튼 내부 (저장 중 등) | `{isSaving && }` | + +**금지 패턴:** +```tsx +// ❌ 텍스트만 사용 금지 +
로딩 중...
+ +// ❌ 직접 스피너 구현 금지 +
+``` + +--- + +*2025-11-27 작성 / 2026-01-12 스피너 규칙 추가* diff --git a/claudedocs/[IMPL-2026-01-12] project-detail-checklist.md b/claudedocs/[IMPL-2026-01-12] project-detail-checklist.md new file mode 100644 index 00000000..842d86dc --- /dev/null +++ b/claudedocs/[IMPL-2026-01-12] project-detail-checklist.md @@ -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 diff --git a/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md new file mode 100644 index 00000000..725dfb81 --- /dev/null +++ b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md @@ -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 전달 + +``` + +--- + +## 변경 이력 + +| 날짜 | 작업 내용 | +|------|----------| +| 2026-01-13 | 체크리스트 문서 생성, MobileFilter 스크롤 버그 수정 | +| 2026-01-13 | 시공관리 mobileFilterSlot → filterConfig 방식으로 변경, 협력업체관리 filterConfig 적용 | diff --git a/claudedocs/[REF] construction-pages-test-urls.md b/claudedocs/[REF] construction-pages-test-urls.md index effd679f..948aea0d 100644 --- a/claudedocs/[REF] construction-pages-test-urls.md +++ b/claudedocs/[REF] construction-pages-test-urls.md @@ -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) - diff --git a/next.config.ts b/next.config.ts index 0774cffd..2b0991c0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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', // 이미지 업로드를 위한 제한 증가 diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx new file mode 100644 index 00000000..41cb4bcf --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx @@ -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>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { + return ( +
+
{error || '기성청구 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx new file mode 100644 index 00000000..596afd43 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx @@ -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>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { + return ( +
+
{error || '기성청구 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx new file mode 100644 index 00000000..6c704ace --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx @@ -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([]); + const [stats, setStats] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx new file mode 100644 index 00000000..8b8285e9 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx @@ -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 ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx new file mode 100644 index 00000000..86244c16 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx new file mode 100644 index 00000000..c8dd33b2 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx @@ -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([]); + const [stats, setStats] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx new file mode 100644 index 00000000..3b06bdca --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx @@ -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(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx new file mode 100644 index 00000000..79138f40 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx @@ -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(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx new file mode 100644 index 00000000..349cc58f --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; + +export default function IssueNewPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx new file mode 100644 index 00000000..1c5fed8d --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx @@ -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([]); + const [stats, setStats] = useState(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 ( +
+
로딩 중...
+
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx new file mode 100644 index 00000000..17601b37 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(protected)/construction/project/utility-management/page.tsx b/src/app/[locale]/(protected)/construction/project/utility-management/page.tsx new file mode 100644 index 00000000..cf94741d --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/utility-management/page.tsx @@ -0,0 +1,5 @@ +import { UtilityManagementListClient } from '@/components/business/construction/utility-management'; + +export default function UtilityManagementPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx b/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx new file mode 100644 index 00000000..6584edcf --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx @@ -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([]); + const [stats, setStats] = useState(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 ; + } + + return ; +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 47e6a63f..e462fa44 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 32b5f1e1..30bd1996 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -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() { />
- {/* 오늘의 이슈 */} - {dashboardSettings.todayIssue.enabled && ( - + {/* 오늘의 이슈 (새 리스트 형태) */} + {dashboardSettings.todayIssueList && ( + )} {/* 일일 일보 */} @@ -230,6 +228,14 @@ export function CEODashboard() { /> )} + {/* 현황판 (구 오늘의 이슈 - 카드 형태) */} + {(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && ( + + )} + {/* 당월 예상 지출 내역 */} {dashboardSettings.monthlyExpense && ( = { +// 현황판 항목 라벨 (구 오늘의 이슈) +const STATUS_BOARD_LABELS: Record = { 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({
- {/* 오늘의 이슈 섹션 */} + {/* 오늘의 이슈 (리스트 형태) */} + + + {/* 일일 일보 */} + handleSectionToggle('dailyReport', checked)} + /> + + {/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
- 오늘의 이슈 + 현황판
- {localSettings.todayIssue.enabled && ( + {(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
- {(Object.keys(TODAY_ISSUE_LABELS) as Array).map( + {(Object.keys(STATUS_BOARD_LABELS) as Array).map( (key) => (
- {TODAY_ISSUE_LABELS[key]} + {STATUS_BOARD_LABELS[key]} - handleTodayIssueItemToggle(key, checked) + handleStatusBoardItemToggle(key, checked) } />
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({ )}
- {/* 일일 일보 */} - handleSectionToggle('dailyReport', checked)} - /> - {/* 당월 예상 지출 내역 */} = { + '수주': '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 ( + + + + +
+ {filteredItems.map((item) => ( + handleItemClick(item.path)} + icon={item.icon} + /> + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx index ba4678c6..3628a66c 100644 --- a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx +++ b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx @@ -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 = { - '수주': 'orders', - '채권 추심': 'debtCollection', - '안전 재고': 'safetyStock', - '세금 신고': 'taxReport', - '신규 업체 등록': 'newVendor', - '연차': 'annualLeave', - '지각': 'lateness', - '결근': 'absence', - '발주': 'purchase', - '결재 요청': 'approvalRequest', +// 뱃지 색상 매핑 +const BADGE_COLORS: Record = { + '수주 성공': '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('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 ( - + {/* 헤더 */} +
+

오늘의 이슈

+ +
-
- {filteredItems.map((item) => ( - handleItemClick(item.path)} - icon={item.icon} - /> - ))} + {/* 리스트 */} +
+ {filteredItems.length === 0 ? ( +
+ 표시할 이슈가 없습니다. +
+ ) : ( + filteredItems.map((item) => ( +
handleItemClick(item)} + > + {/* 좌측: 뱃지 + 내용 */} +
+ + {item.badge} + + + {item.content} + +
+ + {/* 우측: 시간 + 버튼 */} +
+ + {item.time} + + {item.needsApproval && ( +
e.stopPropagation()}> + + +
+ )} +
+
+ )) + )}
diff --git a/src/components/business/CEODashboard/sections/index.ts b/src/components/business/CEODashboard/sections/index.ts index 1d9bee07..a73a33d8 100644 --- a/src/components/business/CEODashboard/sections/index.ts +++ b/src/components/business/CEODashboard/sections/index.ts @@ -1,4 +1,5 @@ export { TodayIssueSection } from './TodayIssueSection'; +export { StatusBoardSection } from './StatusBoardSection'; export { DailyReportSection } from './DailyReportSection'; export { MonthlyExpenseSection } from './MonthlyExpenseSection'; export { CardManagementSection } from './CardManagementSection'; diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index b6852c2b..6fe79857 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -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, + }, + }, }; \ No newline at end of file diff --git a/src/components/business/construction/issue-management/IssueDetailForm.tsx b/src/components/business/construction/issue-management/IssueDetailForm.tsx new file mode 100644 index 00000000..125cbe53 --- /dev/null +++ b/src/components/business/construction/issue-management/IssueDetailForm.tsx @@ -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(null); + + // 철회 다이얼로그 + const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false); + + // 폼 상태 + const [formData, setFormData] = useState({ + 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) => { + 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) => { + 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 ( + + + + + +
+ ) : ( +
+ + + +
+ ) + } + /> + +
+ {/* 이슈 정보 카드 */} + + + 이슈 정보 + + +
+ {/* 이슈번호 */} +
+ + +
+ + {/* 시공번호 */} +
+ + +
+ + {/* 거래처 */} +
+ + +
+ + {/* 현장 */} +
+ + +
+ + {/* 공사PM (자동) */} +
+ + +
+ + {/* 공사담당자 (자동) */} +
+ + +
+ + {/* 보고자 */} +
+ + +
+ + {/* 담당자 */} +
+ + +
+ + {/* 이슈보고일 */} +
+ + +
+ + {/* 이슈해결일 */} +
+ + +
+ + {/* 상태 */} +
+ + +
+
+
+
+ + {/* 이슈 보고 카드 */} + + + 이슈 보고 + + +
+ {/* 구분 & 중요도 */} +
+ {/* 구분 */} +
+ + +
+ + {/* 중요도 */} +
+ + +
+
+ + {/* 제목 */} +
+ + +
+ + {/* 내용 */} +
+
+ + {!isReadOnly && ( + + )} +
+