From c3266e5d3fc4fbaf7eddd0861df2f59f5a4b3ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 15:21:20 +0900 Subject: [PATCH] =?UTF-8?q?deploy:=202026-03-12=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: [QMS] 점검표 템플릿 Mock→API 연동 + 로트심사 UI 개선 - feat: [견적] 제어기 타입 변경 + 가이드레일 제품연동 + 수식보기 개선 - feat: [생산/출하] 작업자 화면 step 서버 토글 + 출하 수주 조인 연동 - feat: [배포] Jenkinsfile 롤백 기능 추가 - feat: [입고] 성적서 파일 백엔드 연동 + CSP 도메인 허용 - feat: ESLint 정리 및 전체 코드 품질 개선 - fix: [QMS] 제품검사 성적서 렌더링 개선 + 빌드 타입 에러 수정 - fix: [품질검사] LegacyPhotoUpload images undefined 에러 수정 - fix: middleware publicRoutes 타입 에러 수정 --- Jenkinsfile | 128 ++++- eslint.config.mjs | 34 +- next.config.ts | 4 + package.json | 2 +- scripts/patch-json-parse.cjs | 46 ++ scripts/validate-next-cache.mjs | 49 ++ .../(protected)/board/[boardCode]/page.tsx | 10 +- .../boards/[boardCode]/[postId]/page.tsx | 2 +- .../(protected)/boards/[boardCode]/page.tsx | 10 +- .../order/base-info/categories/page.tsx | 2 + .../order/order-management/[id]/page.tsx | 2 +- .../project/contract/[id]/page.tsx | 2 +- .../contract/handover-report/[id]/page.tsx | 2 +- .../(protected)/dev/editable-table/page.tsx | 1 - .../(protected)/hr/attendance/page.tsx | 1 + .../(protected)/hr/documents/new/page.tsx | 14 +- .../employee-management/csv-upload/page.tsx | 2 +- .../hr/employee-management/page.tsx | 2 +- .../(protected)/quality/qms/actions.ts | 255 +++++----- .../qms/components/AuditSettingsPanel.tsx | 297 ++++++++---- .../components/ChecklistTemplateEditor.tsx | 449 ++++++++++++++++++ .../qms/components/Day1ChecklistPanel.tsx | 9 +- .../qms/components/Day1DocumentSection.tsx | 267 ++++++++--- .../qms/components/Day1DocumentViewer.tsx | 110 ++++- .../quality/qms/components/DocumentList.tsx | 4 +- .../qms/components/InspectionModal.tsx | 269 +++++++++-- .../quality/qms/components/ReportList.tsx | 17 +- .../quality/qms/components/RouteList.tsx | 9 +- .../documents/ImportInspectionDocument.tsx | 6 +- .../documents/QualityDocumentUploader.tsx | 2 +- .../quality/qms/hooks/useChecklistTemplate.ts | 236 +++++++++ .../quality/qms/hooks/useDay1Audit.ts | 129 ++--- .../quality/qms/hooks/useDay2LotAudit.ts | 109 ++--- .../(protected)/quality/qms/mockData.ts | 8 +- .../[locale]/(protected)/quality/qms/page.tsx | 95 +++- .../[locale]/(protected)/quality/qms/types.ts | 28 ++ .../client-management-sales-admin/page.tsx | 29 +- .../order-management-sales/[id]/page.tsx | 9 +- .../[id]/production-order/page.tsx | 12 +- .../sales/order-management-sales/page.tsx | 34 +- .../production-orders/[id]/page.tsx | 1 - .../production-orders/page.tsx | 14 +- .../sales/pricing-management/[id]/page.tsx | 4 +- .../sales/quote-management/[id]/page.tsx | 3 +- src/app/api/proxy/[...path]/route.ts | 36 +- .../BadDebtCollection/BadDebtDetail.tsx | 2 +- .../BadDebtDetailClientV2.tsx | 3 +- .../accounting/BadDebtCollection/actions.ts | 2 +- .../accounting/BadDebtCollection/index.tsx | 8 +- .../BankTransactionInquiry/index.tsx | 20 +- .../BillManagement/BillManagementClient.tsx | 15 +- .../accounting/BillManagement/index.tsx | 14 +- .../CardTransactionInquiry/actions.ts | 2 +- .../CardTransactionInquiry/index.tsx | 24 +- .../accounting/DailyReport/actions.ts | 2 - .../accounting/DepositManagement/index.tsx | 18 +- .../ExpectedExpenseManagement/index.tsx | 29 +- .../ManualJournalEntryModal.tsx | 2 +- .../accounting/GeneralJournalEntry/index.tsx | 18 +- .../GiftCertificateManagement/index.tsx | 14 +- .../PurchaseManagement/PurchaseDetail.tsx | 14 +- .../accounting/PurchaseManagement/index.tsx | 18 +- .../accounting/ReceivablesStatus/actions.ts | 2 +- .../accounting/ReceivablesStatus/index.tsx | 5 +- .../SalesManagement/SalesDetail.tsx | 4 +- .../accounting/SalesManagement/index.tsx | 16 +- .../accounting/SalesManagement/types.ts | 2 +- .../accounting/TaxInvoiceIssuance/index.tsx | 1 - .../JournalEntryModal.tsx | 4 +- .../accounting/TaxInvoiceManagement/index.tsx | 24 +- .../accounting/VendorLedger/index.tsx | 12 +- .../VendorManagement/VendorDetailClient.tsx | 1 - .../VendorManagementClient.tsx | 18 +- .../accounting/VendorManagement/index.tsx | 12 +- .../accounting/WithdrawalManagement/index.tsx | 16 +- src/components/approval/ApprovalBox/index.tsx | 28 +- .../approval/DocumentCreate/ProposalForm.tsx | 2 +- .../approval/DocumentCreate/actions.ts | 10 +- .../DocumentDetail/DocumentDetailModalV2.tsx | 1 - .../approval/DocumentDetail/index.tsx | 27 -- src/components/approval/DraftBox/index.tsx | 16 +- .../approval/ReferenceBox/index.tsx | 19 +- src/components/atoms/TabChip.tsx | 2 +- src/components/auth/LoginPage.tsx | 4 +- src/components/auth/SignupPage.tsx | 4 +- src/components/board/BoardDetail/index.tsx | 1 - src/components/board/BoardForm/index.tsx | 7 +- .../board/BoardList/BoardListUnified.tsx | 13 +- src/components/board/BoardList/index.tsx | 10 +- .../BoardManagement/BoardDetailClientV2.tsx | 3 +- .../board/BoardManagement/BoardForm.tsx | 2 +- .../board/BoardManagement/index.tsx | 14 +- src/components/board/CommentSection/index.tsx | 2 +- .../business/CEODashboard/CEODashboard.tsx | 8 +- .../business/CEODashboard/components.tsx | 8 - .../modals/DetailModalSections.tsx | 1 - .../CEODashboard/sections/CalendarSection.tsx | 4 +- .../sections/EnhancedSections.tsx | 4 - .../sections/PurchaseStatusSection.tsx | 2 +- .../sections/TodayIssueSection.tsx | 7 +- .../ConstructionMainDashboard.tsx | 3 - .../bidding/BiddingListClient.tsx | 20 +- .../category-management/actions.ts | 3 +- .../contract/ContractDetailForm.tsx | 2 +- .../contract/ContractListClient.tsx | 18 +- .../estimates/EstimateListClient.tsx | 16 +- .../construction/estimates/actions.ts | 6 +- .../sections/EstimateDetailTableSection.tsx | 2 +- .../sections/PriceAdjustmentSection.tsx | 2 +- .../HandoverReportDetailForm.tsx | 2 +- .../HandoverReportListClient.tsx | 16 +- .../construction/handover-report/actions.ts | 2 +- .../issue-management/IssueDetailForm.tsx | 2 +- .../IssueManagementListClient.tsx | 26 +- .../construction/issue-management/actions.ts | 41 +- .../item-management/ItemManagementClient.tsx | 2 +- .../construction/item-management/constants.ts | 2 +- .../LaborManagementClient.tsx | 12 +- .../management/ConstructionDetailClient.tsx | 2 +- .../ConstructionManagementListClient.tsx | 16 +- .../management/ProjectDetailClient.tsx | 2 +- .../management/ProjectKanbanBoard.tsx | 3 +- .../management/ProjectListClient.tsx | 11 +- .../construction/management/StageCard.tsx | 2 +- .../construction/management/actions.ts | 38 +- .../OrderManagementListClient.tsx | 35 +- .../OrderManagementUnified.tsx | 32 +- .../cards/ConstructionDetailCard.tsx | 1 - .../hooks/useOrderDetailForm.ts | 7 +- .../tables/OrderDetailItemTable.tsx | 4 +- .../partners/PartnerListClient.tsx | 14 +- .../ProgressBillingManagementListClient.tsx | 16 +- .../construction/progress-billing/actions.ts | 2 +- .../hooks/useProgressBillingDetailForm.ts | 1 - .../site-briefings/SiteBriefingForm.tsx | 2 +- .../site-briefings/SiteBriefingListClient.tsx | 14 +- .../SiteManagementListClient.tsx | 8 +- .../StructureReviewDetailForm.tsx | 1 - .../StructureReviewListClient.tsx | 14 +- .../UtilityManagementListClient.tsx | 18 +- .../worker-status/WorkerStatusListClient.tsx | 20 +- .../construction/worker-status/actions.ts | 26 +- .../checklist-management/ChecklistForm.tsx | 2 +- .../ChecklistListClient.tsx | 8 +- .../checklist-management/actions.ts | 14 +- .../clients/ClientDetailClientV2.tsx | 2 +- src/components/clients/ClientRegistration.tsx | 2 +- .../common/EditableTable/EditableTable.tsx | 2 +- .../NoticePopupModal/NoticePopupContainer.tsx | 1 - .../NoticePopupModal/NoticePopupModal.tsx | 10 +- src/components/common/ParentMenuRedirect.tsx | 2 +- .../common/ScheduleCalendar/ScheduleBar.tsx | 2 +- .../common/ScheduleCalendar/WeekView.tsx | 3 +- .../EventManagement/EventList.tsx | 12 +- .../customer-center/FAQManagement/FAQList.tsx | 4 +- .../InquiryManagement/InquiryList.tsx | 7 +- .../NoticeManagement/NoticeList.tsx | 10 +- src/components/dev/DevToolbar.tsx | 2 +- .../dev/generators/accountingData.ts | 11 +- src/components/dev/generators/quoteData.ts | 2 - .../document-system/viewer/DocumentViewer.tsx | 2 - .../AttendanceInfoDialog.tsx | 2 +- .../AttendanceManagement/ReasonInfoDialog.tsx | 1 - .../hr/AttendanceManagement/index.tsx | 37 +- .../hr/CalendarManagement/index.tsx | 14 +- src/components/hr/CardManagement/index.tsx | 12 +- .../hr/DepartmentManagement/index.tsx | 2 +- .../hr/EmployeeManagement/EmployeeForm.tsx | 46 +- .../hr/EmployeeManagement/EmployeeToolbar.tsx | 2 +- .../hr/EmployeeManagement/actions.ts | 19 +- .../hr/EmployeeManagement/index.tsx | 54 ++- src/components/hr/EmployeeManagement/utils.ts | 26 +- .../SalaryRegistrationDialog.tsx | 2 +- src/components/hr/SalaryManagement/index.tsx | 31 +- .../VacationGrantDialog.tsx | 1 - .../VacationRegisterDialog.tsx | 1 - .../VacationRequestDialog.tsx | 1 - .../hr/VacationManagement/actions.ts | 2 +- .../hr/VacationManagement/index.tsx | 56 +-- .../DynamicItemForm/fields/ComputedField.tsx | 2 +- .../DynamicItemForm/fields/CurrencyField.tsx | 4 +- .../DynamicItemForm/fields/NumberField.tsx | 1 - src/components/items/ItemDetailClient.tsx | 10 +- src/components/items/ItemDetailView.tsx | 2 - .../items/ItemForm/BendingDiagramSection.tsx | 4 +- .../items/ItemForm/forms/ProductForm.tsx | 14 +- src/components/items/ItemListClient.tsx | 38 +- .../items/ItemMasterDataManagement.tsx | 1 - .../components/DraggableField.tsx | 4 +- .../components/DraggableSection.tsx | 2 +- .../components/ItemMasterDialogs.tsx | 5 +- .../dialogs/ColumnDialog.tsx | 2 +- .../dialogs/FieldDialog.tsx | 2 +- .../dialogs/LoadTemplateDialog.tsx | 2 +- .../dialogs/MasterFieldDialog.tsx | 2 +- .../hooks/useAttributeManagement.ts | 3 +- .../hooks/useDeleteManagement.ts | 6 +- .../hooks/useInitialDataLoading.ts | 2 +- .../hooks/useMasterFieldManagement.ts | 2 +- .../hooks/usePageManagement.ts | 6 +- .../hooks/useSectionManagement.ts | 3 +- .../hooks/useTabManagement.ts | 2 +- .../hooks/useTemplateManagement.ts | 16 +- .../services/masterFieldService.ts | 2 +- .../tabs/HierarchyTab/index.tsx | 2 +- .../tabs/MasterFieldTab/index.tsx | 8 +- .../tabs/SectionsTab.tsx | 8 +- .../ImportInspectionInputModal.tsx | 3 +- .../ReceivingManagement/ReceivingDetail.tsx | 2 +- .../ReceivingManagement/ReceivingList.tsx | 24 +- .../material/ReceivingManagement/actions.ts | 4 +- .../material/StockStatus/StockAuditModal.tsx | 2 +- .../StockStatus/StockStatusDetail.tsx | 2 +- .../material/StockStatus/StockStatusList.tsx | 16 +- src/components/molecules/CopyableCell.tsx | 61 +++ .../molecules/DateRangeSelector.tsx | 2 +- src/components/molecules/FormField.tsx | 7 +- src/components/molecules/MobileFilter.tsx | 2 +- src/components/molecules/StandardDialog.tsx | 2 +- src/components/molecules/StatusBadge.tsx | 3 +- src/components/molecules/index.ts | 4 +- src/components/orders/OrderRegistration.tsx | 3 +- .../orders/OrderSalesDetailEdit.tsx | 10 +- .../orders/OrderSalesDetailView.tsx | 7 +- src/components/orders/actions.ts | 2 +- .../orders/documents/ContractDocument.tsx | 4 +- .../orders/documents/SalesOrderDocument.tsx | 2 +- .../orders/documents/TransactionDocument.tsx | 8 +- src/components/organisms/DataTable.tsx | 17 +- src/components/organisms/MobileCard.tsx | 7 +- src/components/organisms/SearchFilter.tsx | 4 +- .../ShipmentManagement/ShipmentDetail.tsx | 2 +- .../ShipmentManagement/ShipmentList.tsx | 20 +- .../outbound/ShipmentManagement/actions.ts | 12 +- .../documents/TransactionStatement.tsx | 4 +- .../VehicleDispatchList.tsx | 22 +- .../PriceDistributionDetail.tsx | 2 +- .../PriceDistributionList.tsx | 10 +- .../PricingTableListClient.tsx | 24 +- src/components/pricing/PricingFormClient.tsx | 3 +- .../pricing/PricingHistoryDialog.tsx | 2 +- src/components/pricing/PricingListClient.tsx | 22 +- src/components/pricing/actions.ts | 2 +- src/components/pricing/types.ts | 1 - .../ProcessDetailClientV2.tsx | 4 +- .../process-management/ProcessForm.tsx | 8 +- .../process-management/ProcessListClient.tsx | 14 +- .../ProcessWorkLogContent.tsx | 2 +- .../process-management/RuleModal.tsx | 6 +- .../process-management/StepForm.tsx | 2 +- src/components/process-management/actions.ts | 2 +- .../production/ProductionDashboard/index.tsx | 4 +- .../production/ProductionOrders/actions.ts | 1 - .../WorkOrders/WipProductionModal.tsx | 2 +- .../production/WorkOrders/WorkOrderCreate.tsx | 2 +- .../production/WorkOrders/WorkOrderEdit.tsx | 2 +- .../production/WorkOrders/WorkOrderList.tsx | 22 +- .../documents/BendingWorkLogContent.tsx | 2 +- .../documents/SlatWorkLogContent.tsx | 2 +- .../documents/TemplateInspectionContent.tsx | 21 +- .../documents/bending/GuideRailSection.tsx | 2 +- .../WorkOrders/documents/bending/utils.ts | 6 +- .../documents/inspection-shared.tsx | 2 +- .../production/WorkResults/WorkResultList.tsx | 26 +- .../WorkerScreen/InspectionInputModal.tsx | 3 +- .../WorkerScreen/ProcessDetailSection.tsx | 1 - .../production/WorkerScreen/WorkItemCard.tsx | 4 +- .../production/WorkerScreen/index.tsx | 24 +- .../InspectionManagement/InspectionCreate.tsx | 4 +- .../InspectionManagement/InspectionDetail.tsx | 7 +- .../InspectionManagement/InspectionList.tsx | 21 +- .../ProductInspectionInputModal.tsx | 4 +- .../documents/FqcDocumentContent.tsx | 2 +- .../documents/FqcRequestDocumentContent.tsx | 2 +- .../quality/InspectionManagement/mockData.ts | 4 +- .../PerformanceReportList.tsx | 28 +- src/components/quotes/DiscountModal.tsx | 1 - src/components/quotes/FormulaViewModal.tsx | 129 ++++- src/components/quotes/LocationDetailPanel.tsx | 32 +- src/components/quotes/LocationEditModal.tsx | 13 +- src/components/quotes/LocationListPanel.tsx | 37 +- src/components/quotes/QuoteFooterBar.tsx | 2 +- .../quotes/QuoteManagementClient.tsx | 24 +- src/components/quotes/QuoteRegistration.tsx | 88 ++-- src/components/quotes/actions.ts | 4 +- src/components/quotes/types.ts | 6 +- .../reports/ComprehensiveAnalysis/index.tsx | 3 +- .../settings/AccountInfoManagement/actions.ts | 25 +- .../settings/AccountManagement/index.tsx | 18 +- .../AddCompanyDialog.tsx | 3 +- .../settings/CompanyInfoManagement/index.tsx | 2 +- .../settings/LeavePolicyManagement/index.tsx | 2 - .../PaymentHistoryClient.tsx | 20 +- .../PaymentHistoryManagement/index.tsx | 24 +- .../PermissionDetailClient.tsx | 2 +- .../settings/PermissionManagement/index.tsx | 10 +- .../settings/PopupManagement/PopupList.tsx | 10 +- .../SubscriptionClient.tsx | 2 +- .../settings/WorkScheduleManagement/index.tsx | 1 - .../IntegratedDetailTemplate/FieldInput.tsx | 1 - .../IntegratedDetailTemplate/types.ts | 4 +- .../templates/IntegratedListTemplateV2.tsx | 89 +++- .../templates/UniversalListPage/index.tsx | 4 +- .../templates/UniversalListPage/types.ts | 4 +- src/components/ui/confirm-dialog.tsx | 3 +- src/components/ui/currency-input.tsx | 2 +- src/components/ui/file-list.tsx | 6 +- src/components/ui/loading-spinner.tsx | 2 +- src/components/ui/number-input.tsx | 4 +- src/components/ui/quantity-input.tsx | 2 +- src/components/ui/skeleton.tsx | 2 +- .../vehicle-management/ForkliftList/index.tsx | 24 +- .../VehicleDetail/index.tsx | 1 - .../vehicle-management/VehicleList/actions.ts | 2 +- .../vehicle-management/VehicleList/index.tsx | 24 +- .../VehicleLogDetail/index.tsx | 1 - .../VehicleLogList/index.tsx | 8 +- src/contexts/ApiErrorContext.tsx | 2 - src/contexts/ItemMasterContext.tsx | 7 - src/hooks/useAccountingListPage.ts | 2 +- src/hooks/useCEODashboard.ts | 2 +- src/hooks/useDashboardFetch.ts | 2 +- src/hooks/useStatsLoader.ts | 2 +- src/layouts/AuthenticatedLayout.tsx | 9 +- src/lib/api/execute-server-action.ts | 18 +- src/lib/api/index.ts | 14 +- src/lib/api/item-master.ts | 5 - src/lib/api/quote.ts | 1 - src/lib/api/refresh-token.ts | 6 +- src/lib/api/safe-json-parse.ts | 98 ++++ src/lib/auth/logout.ts | 8 +- src/lib/capacitor/fcm.ts | 2 +- src/lib/print-utils.ts | 2 +- src/lib/utils/excel-download.ts | 4 - src/lib/utils/fileDownload.ts | 12 +- src/lib/utils/locale.ts | 2 +- src/lib/utils/validation/form-schemas.ts | 4 +- src/lib/utils/validation/item-schemas.ts | 1 - src/middleware.ts | 25 +- src/stores/item-master/useItemMasterStore.ts | 3 - src/stores/utils/userStorage.ts | 2 +- 341 files changed, 3738 insertions(+), 1999 deletions(-) create mode 100644 scripts/patch-json-parse.cjs create mode 100644 scripts/validate-next-cache.mjs create mode 100644 src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/hooks/useChecklistTemplate.ts create mode 100644 src/components/molecules/CopyableCell.tsx create mode 100644 src/lib/api/safe-json-parse.ts diff --git a/Jenkinsfile b/Jenkinsfile index 50cef9df..2755de86 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,12 @@ pipeline { agent any + parameters { + choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백') + choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경') + string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백') + } + options { disableConcurrentBuilds() } @@ -8,10 +14,66 @@ pipeline { environment { DEPLOY_USER = 'hskwon' RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') + PROD_SERVER = '211.117.60.189' } stages { + + // ── 롤백: 릴리스 목록 조회 ── + stage('Rollback: List Releases') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage' + def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage' + sshagent(credentials: ['deploy-ssh-key']) { + def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim() + def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim() + echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ===" + echo "현재 활성: ${current}" + echo "사용 가능:\n${releases}" + } + } + } + } + + // ── 롤백: symlink 전환 ── + stage('Rollback: Switch Release') { + when { expression { params.ACTION == 'rollback' } } + steps { + script { + def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage' + def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage' + + sshagent(credentials: ['deploy-ssh-key']) { + def targetRelease = params.ROLLBACK_RELEASE + if (!targetRelease?.trim()) { + targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim() + } + + // 릴리스 존재 여부 확인 + sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'" + + slackSend channel: '#deploy_react', color: '#FF9800', tokenCredentialId: 'slack-token', + message: "🔄 *react* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + + sh """ + ssh ${DEPLOY_USER}@${PROD_SERVER} ' + ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current && + cd /home/webservice && pm2 reload ${pmName} + ' + """ + + slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *react* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } + } + } + + // ── 일반 배포: Checkout ── stage('Checkout') { + when { expression { params.ACTION == 'deploy' } } steps { checkout scm script { @@ -23,6 +85,7 @@ pipeline { } stage('Prepare Env') { + when { expression { params.ACTION == 'deploy' } } steps { script { if (env.BRANCH_NAME == 'main') { @@ -37,16 +100,23 @@ pipeline { } stage('Install') { + when { expression { params.ACTION == 'deploy' } } steps { sh 'npm install --prefer-offline' } } stage('Build') { + when { expression { params.ACTION == 'deploy' } } steps { sh 'npm run build' } } // ── develop → 개발서버 배포 ── stage('Deploy Development') { - when { branch 'develop' } + when { + allOf { + branch 'develop' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ @@ -63,16 +133,21 @@ pipeline { // ── main → 운영서버 Stage 배포 ── stage('Deploy Stage') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}' rsync -az --delete \ .next package.json next.config.ts public node_modules \ - ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/ - scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production - ssh ${DEPLOY_USER}@211.117.60.189 ' + ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/ + scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production + ssh ${DEPLOY_USER}@${PROD_SERVER} ' ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current && cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 && cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true @@ -97,7 +172,12 @@ pipeline { // ── main → Production 재빌드 (운영 환경변수) ── stage('Rebuild for Production') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production" sh 'npm run build' @@ -106,16 +186,21 @@ pipeline { // ── main → 운영서버 Production 배포 ── stage('Deploy Production') { - when { branch 'main' } + when { + allOf { + branch 'main' + expression { params.ACTION == 'deploy' } + } + } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ - ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}' + ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}' rsync -az --delete \ .next package.json next.config.ts public node_modules \ - ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/ - scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production - ssh ${DEPLOY_USER}@211.117.60.189 ' + ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/ + scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/.env.production + ssh ${DEPLOY_USER}@${PROD_SERVER} ' ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current && cd /home/webservice && pm2 reload sam-front && cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true @@ -128,12 +213,23 @@ pipeline { post { success { - slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token', - message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + script { + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } } failure { - slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token', - message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + script { + if (params.ACTION == 'deploy') { + slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } else { + slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *react* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } + } } } } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 3e772ff2..6e9e06a9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ const eslintConfig = [ "node_modules/**", "next-env.d.ts", "src/components/_unused/**", // Archived unused components + "src/components/settings/AccountManagement/_legacy/**", // Legacy files "src/hooks/useCurrentTime.ts", // Demo hook ], }, @@ -76,9 +77,38 @@ const eslintConfig = [ HTMLTableCaptionElement: "readonly", HTMLTextAreaElement: "readonly", HTMLCanvasElement: "readonly", + HTMLDivElement: "readonly", + HTMLElement: "readonly", + HTMLImageElement: "readonly", ImageData: "readonly", Image: "readonly", prompt: "readonly", + Audio: "readonly", + Blob: "readonly", + CSSStyleDeclaration: "readonly", + CustomEvent: "readonly", + Element: "readonly", + ErrorEvent: "readonly", + Event: "readonly", + FileList: "readonly", + FileReader: "readonly", + Headers: "readonly", + IntersectionObserver: "readonly", + KeyboardEvent: "readonly", + MouseEvent: "readonly", + Node: "readonly", + NodeJS: "readonly", + PromiseRejectionEvent: "readonly", + RequestCache: "readonly", + ResizeObserver: "readonly", + Storage: "readonly", + cancelAnimationFrame: "readonly", + crypto: "readonly", + getComputedStyle: "readonly", + google: "readonly", + navigator: "readonly", + requestAnimationFrame: "readonly", + sessionStorage: "readonly", }, }, plugins: { @@ -95,7 +125,9 @@ const eslintConfig = [ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_" }], }, }, diff --git a/next.config.ts b/next.config.ts index c68ac77b..d86b9b1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility + allowedDevOrigins: ['192.168.0.*'], // 로컬 네트워크 기기 접속 허용 serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외 images: { remotePatterns: [ @@ -28,6 +29,9 @@ const nextConfig: NextConfig = { }, // Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외 webpack: (config, { isServer }) => { + // macOS 26 호환성: webpack 캐시 비활성화 (rename ENOENT 방지) + config.cache = false; + if (!isServer) { config.resolve.fallback = { ...config.resolve.fallback, diff --git a/package.json b/package.json index cc3323bd..26d82403 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev", "build": "next build", "build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &", "start": "next start -H 0.0.0.0", diff --git a/scripts/patch-json-parse.cjs b/scripts/patch-json-parse.cjs new file mode 100644 index 00000000..700cde2f --- /dev/null +++ b/scripts/patch-json-parse.cjs @@ -0,0 +1,46 @@ +/** + * JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응 + * + * macOS 26에서 atomic write(tmp + rename)가 실패하면 + * .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨. + * 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생. + * + * 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구. + * NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드. + */ +'use strict'; + +const originalParse = JSON.parse; + +JSON.parse = function patchedJsonParse(text, reviver) { + try { + return originalParse.call(this, text, reviver); + } catch (e) { + if (e instanceof SyntaxError && typeof text === 'string') { + // "Unexpected non-whitespace character after JSON at position N" + // → position N까지가 유효한 JSON + const match = e.message.match(/after JSON at position\s+(\d+)/); + if (match) { + const pos = parseInt(match[1], 10); + if (pos > 0) { + try { + const result = originalParse.call(this, text.substring(0, pos), reviver); + // 한 번만 경고 (같은 position이면 반복 출력 방지) + if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set(); + const key = pos + ':' + text.length; + if (!patchedJsonParse._warned.has(key)) { + patchedJsonParse._warned.add(key); + console.warn( + `[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)` + ); + } + return result; + } catch { + // truncation으로도 실패하면 원래 에러 throw + } + } + } + } + throw e; + } +}; diff --git a/scripts/validate-next-cache.mjs b/scripts/validate-next-cache.mjs new file mode 100644 index 00000000..cdb93902 --- /dev/null +++ b/scripts/validate-next-cache.mjs @@ -0,0 +1,49 @@ +/** + * .next 빌드 캐시 무결성 검증 + * + * macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음. + * (atomic write 실패 → 데이터 중복 기록) + * dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제. + */ +import { readFileSync, rmSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; + +const NEXT_DIR = '.next'; + +if (!existsSync(NEXT_DIR)) { + process.exit(0); +} + +const jsonFiles = []; +try { + // .next/ 루트의 JSON 파일들 + for (const f of readdirSync(NEXT_DIR)) { + if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f)); + } + // .next/server/ 의 JSON 파일들 + const serverDir = join(NEXT_DIR, 'server'); + if (existsSync(serverDir)) { + for (const f of readdirSync(serverDir)) { + if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f)); + } + } +} catch { + // 디렉토리 읽기 실패 시 무시 +} + +let corrupted = false; +for (const file of jsonFiles) { + try { + const content = readFileSync(file, 'utf8'); + JSON.parse(content); + } catch (e) { + console.warn(`⚠️ 손상된 캐시 발견: ${file}`); + console.warn(` ${e.message}`); + corrupted = true; + } +} + +if (corrupted) { + console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...'); + rmSync(NEXT_DIR, { recursive: true, force: true }); +} diff --git a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx index bf9489ab..0762896d 100644 --- a/src/app/[locale]/(protected)/board/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/board/[boardCode]/page.tsx @@ -103,7 +103,7 @@ function BoardListContent({ boardCode }: { boardCode: string }) { // 게시글 목록 const [posts, setPosts] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [, setIsLoading] = useState(true); const [error, setError] = useState(null); // 필터 및 검색 @@ -239,11 +239,11 @@ function BoardListContent({ boardCode }: { boardCode: string }) { // 테이블 컬럼 const tableColumns: TableColumn[] = useMemo(() => [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, - { key: 'title', label: '제목', className: 'min-w-[200px]' }, - { key: 'author', label: '작성자', className: 'w-[120px]' }, - { key: 'views', label: '조회수', className: 'w-[80px] text-center' }, + { key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true }, + { key: 'author', label: '작성자', className: 'w-[120px]', copyable: true }, + { key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true }, { key: 'status', label: '상태', className: 'w-[100px] text-center' }, - { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, + { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true }, ], []); // 테이블 행 렌더링 diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx index e8f9a3b4..ce0044ee 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/page.tsx @@ -29,7 +29,7 @@ import { deleteDynamicBoardPost, } from '@/components/board/DynamicBoard/actions'; import { getBoardByCode } from '@/components/board/BoardManagement/actions'; -import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types'; +import { transformApiToComment } from '@/components/customer-center/shared/types'; import type { PostApiData } from '@/components/customer-center/shared/types'; import { sanitizeHTML } from '@/lib/sanitize'; diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx index fa0a3698..35d7c99d 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx @@ -110,7 +110,7 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) { // 게시글 목록 const [posts, setPosts] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [, setIsLoading] = useState(true); const [error, setError] = useState(null); // 필터 및 검색 @@ -246,11 +246,11 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) { // 테이블 컬럼 const tableColumns: TableColumn[] = useMemo(() => [ { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, - { key: 'title', label: '제목', className: 'min-w-[200px]' }, - { key: 'author', label: '작성자', className: 'w-[120px]' }, - { key: 'views', label: '조회수', className: 'w-[80px] text-center' }, + { key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true }, + { key: 'author', label: '작성자', className: 'w-[120px]', copyable: true }, + { key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true }, { key: 'status', label: '상태', className: 'w-[100px] text-center' }, - { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, + { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true }, ], []); // 테이블 행 렌더링 diff --git a/src/app/[locale]/(protected)/construction/order/base-info/categories/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/categories/page.tsx index 21d41fe4..97878424 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/categories/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/categories/page.tsx @@ -1,3 +1,5 @@ +'use client'; + import { CategoryManagement } from '@/components/business/construction/category-management'; export default function CategoriesPage() { diff --git a/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx index 9673157c..076458f5 100644 --- a/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx @@ -18,7 +18,7 @@ interface OrderDetailPageProps { export default function OrderDetailPage({ params }: OrderDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const _router = useRouter(); const searchParams = useSearchParams(); const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const [data, setData] = useState>['data']>(undefined); diff --git a/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx index c25aa623..f809632d 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx @@ -13,7 +13,7 @@ interface ContractDetailPageProps { export default function ContractDetailPage({ params }: ContractDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const _router = useRouter(); const searchParams = useSearchParams(); const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const [data, setData] = useState>['data']>(undefined); diff --git a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx index 9288b165..979cc5b4 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx @@ -15,7 +15,7 @@ interface HandoverReportDetailPageProps { export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const _router = useRouter(); const searchParams = useSearchParams(); const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const [data, setData] = useState>['data']>(undefined); diff --git a/src/app/[locale]/(protected)/dev/editable-table/page.tsx b/src/app/[locale]/(protected)/dev/editable-table/page.tsx index e445b695..25b1208a 100644 --- a/src/app/[locale]/(protected)/dev/editable-table/page.tsx +++ b/src/app/[locale]/(protected)/dev/editable-table/page.tsx @@ -4,7 +4,6 @@ import { useState, useCallback } from 'react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { EditableTable, EditableColumn } from '@/components/common/EditableTable'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { diff --git a/src/app/[locale]/(protected)/hr/attendance/page.tsx b/src/app/[locale]/(protected)/hr/attendance/page.tsx index 23d7a935..e141c0a2 100644 --- a/src/app/[locale]/(protected)/hr/attendance/page.tsx +++ b/src/app/[locale]/(protected)/hr/attendance/page.tsx @@ -75,6 +75,7 @@ export default function AttendancePage() { setSiteLocation(finalLocation); } else { + // no fallback location needed } } catch (error) { console.error('[AttendancePage] loadSettings error:', error); diff --git a/src/app/[locale]/(protected)/hr/documents/new/page.tsx b/src/app/[locale]/(protected)/hr/documents/new/page.tsx index 6447f230..f38300d5 100644 --- a/src/app/[locale]/(protected)/hr/documents/new/page.tsx +++ b/src/app/[locale]/(protected)/hr/documents/new/page.tsx @@ -11,23 +11,17 @@ 'use client'; import { useSearchParams, useRouter } from 'next/navigation'; -import { useState, useEffect, useMemo, Suspense } from 'react'; -import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react'; +import { useState, useMemo, Suspense } from 'react'; +import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; 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 { FormSectionSkeleton } from '@/components/ui/skeleton'; import { format } from 'date-fns'; -import { ko } from 'date-fns/locale'; import { toast } from 'sonner'; // 문서 유형 라벨 diff --git a/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx index dcac80c4..02a3c8d1 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx @@ -4,7 +4,7 @@ import { CSVUploadPage } from '@/components/hr/EmployeeManagement/CSVUploadPage' import type { Employee } from '@/components/hr/EmployeeManagement/types'; export default function EmployeeCSVUploadPage() { - const handleUpload = (employees: Employee[]) => { + const handleUpload = (_employees: Employee[]) => { // TODO: API 연동 }; diff --git a/src/app/[locale]/(protected)/hr/employee-management/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/page.tsx index 8677750a..b46ccf5e 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/page.tsx @@ -50,7 +50,7 @@ function EmployeeManagementContent() { toast.error(errorMessage); return { success: false, error: errorMessage }; } - } catch (error) { + } catch (_error) { toast.error('서버 오류가 발생했습니다.'); return { success: false, error: '서버 오류가 발생했습니다.' }; } diff --git a/src/app/[locale]/(protected)/quality/qms/actions.ts b/src/app/[locale]/(protected)/quality/qms/actions.ts index 0fbfb8bf..0a9e85cd 100644 --- a/src/app/[locale]/(protected)/quality/qms/actions.ts +++ b/src/app/[locale]/(protected)/quality/qms/actions.ts @@ -22,6 +22,7 @@ interface RouteItemApi { id: number; code: string; date: string; + client: string; site: string; location_count: number; sub_items: { @@ -44,36 +45,7 @@ interface DocumentApi { date: string; code?: string; sub_type?: string; - }[]; -} - -interface ChecklistDetailApi { - id: number; - year: number; - quarter: number; - type: string; - status: string; - progress: { completed: number; total: number }; - categories: { - id: number; - title: string; - sort_order: number; - sub_items: { - id: number; - name: string; - description?: string; - is_completed: boolean; - completed_at?: string; - sort_order: number; - standard_documents: { - id: number; - title: string; - version: string; - date: string; - file_name?: string; - file_url?: string; - }[]; - }[]; + work_order_id?: number; }[]; } @@ -98,6 +70,7 @@ function transformRouteApi(api: RouteItemApi) { id: String(api.id), code: api.code, date: api.date, + client: api.client, site: api.site, locationCount: api.location_count, subItems: api.sub_items.map((s) => ({ @@ -122,43 +95,11 @@ function transformDocumentApi(api: DocumentApi) { date: i.date, code: i.code, subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined, + workOrderId: i.work_order_id, })), }; } -function transformChecklistDetail(api: ChecklistDetailApi) { - return { - progress: api.progress, - categories: api.categories.map((cat) => ({ - id: String(cat.id), - title: cat.title, - subItems: cat.sub_items.map((item) => ({ - id: String(item.id), - name: item.name, - isCompleted: item.is_completed, - })), - })), - checkItems: api.categories.flatMap((cat) => - cat.sub_items.map((item) => ({ - id: `check-${item.id}`, - categoryId: String(cat.id), - subItemId: String(item.id), - title: item.name, - description: item.description || '', - buttonLabel: '기준/매뉴얼 확인', - standardDocuments: item.standard_documents.map((doc) => ({ - id: String(doc.id), - title: doc.title, - version: doc.version, - date: doc.date, - fileName: doc.file_name, - fileUrl: doc.file_url, - })), - })), - ), - }; -} - // ===== 2일차: 로트 추적 심사 ===== export async function getQualityReports(params: { @@ -216,39 +157,14 @@ export async function confirmUnitInspection(unitId: string, confirmed: boolean) }); } -// ===== 1일차: 기준/매뉴얼 심사 ===== +// ===== 1일차: 점검표 항목 토글 ===== -export async function getChecklistDetail(params: { - year: number; - quarter?: number; -}) { +export async function toggleTemplateItem(templateId: number, subItemId: string) { return executeServerAction({ - url: buildApiUrl('/api/v1/qms/checklists', { - year: params.year, - quarter: params.quarter, - }), - transform: (data: { items: { id: number }[] }) => { - if (data.items.length === 0) return null; - return { checklistId: String(data.items[0].id) }; - }, - errorMessage: '점검표 목록 조회에 실패했습니다.', - }); -} - -export async function getChecklistById(id: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklists/${id}`), - transform: (data: ChecklistDetailApi) => transformChecklistDetail(data), - errorMessage: '점검표 상세 조회에 실패했습니다.', - }); -} - -export async function toggleChecklistItem(itemId: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/toggle`), + url: buildApiUrl(`/api/v1/quality/checklist-templates/${templateId}/items/${subItemId}/toggle`), method: 'PATCH', - transform: (data: { id: number; name: string; is_completed: boolean; completed_at?: string }) => ({ - id: String(data.id), + transform: (data: { id: string; name: string; is_completed: boolean; completed_at?: string }) => ({ + id: data.id, name: data.name, isCompleted: data.is_completed, }), @@ -256,51 +172,136 @@ export async function toggleChecklistItem(itemId: string) { }); } -export async function getCheckItemDocuments(itemId: string) { +// ===== 점검표 템플릿 관리 (설정 모달) ===== + +interface ChecklistTemplateApi { + id: number; + name: string; + type: string; + categories: { + id: string; + title: string; + subItems: { id: string; name: string; is_completed?: boolean }[]; + }[]; + options: Record | null; + file_counts: Record; + updated_at: string | null; + updated_by: string | null; +} + +interface TemplateDocumentApi { + id: number; + field_key: string; + display_name: string; + file_size: number; + mime_type: string; + uploaded_by: string | null; + created_at: string | null; +} + +export async function getChecklistTemplate(type: string = 'day1_audit') { return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), - transform: (data: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }[]) => - data.map((d) => ({ - id: String(d.id), - title: d.title, - version: d.version, - date: d.date, - fileName: d.file_name, - fileUrl: d.file_url, + url: buildApiUrl('/api/v1/quality/checklist-templates', { type }), + transform: (data: ChecklistTemplateApi) => ({ + id: data.id, + name: data.name, + type: data.type, + categories: data.categories.map((cat) => ({ + id: cat.id, + title: cat.title, + subItems: cat.subItems.map((item) => ({ + id: item.id, + name: item.name, + isCompleted: item.is_completed ?? false, + })), })), - errorMessage: '기준 문서 조회에 실패했습니다.', + options: data.options, + fileCounts: data.file_counts, + updatedAt: data.updated_at, + updatedBy: data.updated_by, + }), + errorMessage: '점검표 템플릿 조회에 실패했습니다.', }); } -export async function attachStandardDocument( - itemId: string, - data: { title: string; version?: string; date?: string; documentId?: number }, +export async function saveChecklistTemplate( + id: number, + data: { name?: string; categories: { id: string; title: string; subItems: { id: string; name: string }[] }[]; options?: Record }, ) { return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), - method: 'POST', - body: { - title: data.title, - version: data.version, - date: data.date, - document_id: data.documentId, - }, - transform: (d: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }) => ({ - id: String(d.id), - title: d.title, - version: d.version, - date: d.date, - fileName: d.file_name, - fileUrl: d.file_url, + url: buildApiUrl(`/api/v1/quality/checklist-templates/${id}`), + method: 'PUT', + body: data, + transform: (result: ChecklistTemplateApi) => ({ + id: result.id, + name: result.name, + type: result.type, + categories: result.categories.map((cat) => ({ + id: cat.id, + title: cat.title, + subItems: cat.subItems.map((item) => ({ + id: item.id, + name: item.name, + isCompleted: item.is_completed ?? false, + })), + })), + options: result.options, + fileCounts: result.file_counts, + updatedAt: result.updated_at, + updatedBy: result.updated_by, }), - errorMessage: '기준 문서 연결에 실패했습니다.', + errorMessage: '점검표 템플릿 저장에 실패했습니다.', }); } -export async function detachStandardDocument(itemId: string, docId: string) { +export async function getTemplateDocuments(templateId: number, subItemId?: string) { return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents/${docId}`), - method: 'DELETE', - errorMessage: '기준 문서 연결 해제에 실패했습니다.', + url: buildApiUrl('/api/v1/quality/qms-documents', { + template_id: templateId, + sub_item_id: subItemId, + }), + transform: (data: TemplateDocumentApi[]) => + data.map((d) => ({ + id: d.id, + fieldKey: d.field_key, + displayName: d.display_name, + fileSize: d.file_size, + mimeType: d.mime_type, + uploadedBy: d.uploaded_by, + createdAt: d.created_at, + })), + errorMessage: '템플릿 문서 조회에 실패했습니다.', + }); +} + +export async function uploadTemplateDocument(templateId: number, subItemId: string, file: File) { + const formData = new FormData(); + formData.append('template_id', String(templateId)); + formData.append('sub_item_id', subItemId); + formData.append('file', file); + + return executeServerAction({ + url: buildApiUrl('/api/v1/quality/qms-documents'), + method: 'POST', + body: formData, + transform: (d: TemplateDocumentApi) => ({ + id: d.id, + fieldKey: d.field_key, + displayName: d.display_name, + fileSize: d.file_size, + mimeType: d.mime_type, + createdAt: d.created_at, + }), + errorMessage: '파일 업로드에 실패했습니다.', + }); +} + +export async function deleteTemplateDocument(fileId: number, replace: boolean = false) { + return executeServerAction({ + url: buildApiUrl(`/api/v1/quality/qms-documents/${fileId}`, { + replace: replace ? 'true' : undefined, + }), + method: 'DELETE', + errorMessage: '파일 삭제에 실패했습니다.', }); } diff --git a/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx index 544080de..ae8fb2ac 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx @@ -1,9 +1,11 @@ 'use client'; -import React from 'react'; -import { Settings, X, Eye, EyeOff } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import React, { useState } from 'react'; +import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { ChecklistTemplateEditor } from './ChecklistTemplateEditor'; +import type { ChecklistCategory } from '../types'; export interface AuditDisplaySettings { showProgressBar: boolean; @@ -13,19 +15,46 @@ export interface AuditDisplaySettings { expandAllCategories: boolean; } +// 점검표 관리 props +export interface ChecklistManagementProps { + categories: ChecklistCategory[]; + hasChanges: boolean; + saving: boolean; + loading?: boolean; + error?: string | null; + onAddCategory: () => void; + onUpdateCategoryTitle: (categoryId: string, title: string) => void; + onDeleteCategory: (categoryId: string) => void; + onMoveCategoryUp: (index: number) => void; + onMoveCategoryDown: (index: number) => void; + onAddSubItem: (categoryId: string) => void; + onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void; + onDeleteSubItem: (categoryId: string, subItemId: string) => void; + onMoveSubItemUp: (categoryId: string, index: number) => void; + onMoveSubItemDown: (categoryId: string, index: number) => void; + onSave: () => void; + onReset: () => void; +} + interface AuditSettingsPanelProps { isOpen: boolean; onClose: () => void; settings: AuditDisplaySettings; onSettingsChange: (settings: AuditDisplaySettings) => void; + checklistManagement?: ChecklistManagementProps; } +type TabType = 'display' | 'checklist'; + export function AuditSettingsPanel({ isOpen, onClose, settings, onSettingsChange, + checklistManagement, }: AuditSettingsPanelProps) { + const [activeTab, setActiveTab] = useState('display'); + const handleToggle = (key: keyof AuditDisplaySettings) => { onSettingsChange({ ...settings, @@ -49,7 +78,7 @@ export function AuditSettingsPanel({
-

화면 설정

+

설정

- {/* 설정 항목 */} -
- {/* 레이아웃 섹션 */} -
-

레이아웃

-
- handleToggle('showProgressBar')} - /> - handleToggle('showDocumentViewer')} - /> - handleToggle('showDocumentSection')} - /> -
-
- - {/* 구분선 */} -
- - {/* 점검표 섹션 */} -
-

점검표 옵션

-
- handleToggle('showCompletedItems')} - /> - handleToggle('expandAllCategories')} - /> -
-
- - {/* 구분선 */} -
- - {/* 빠른 설정 */} -
-

빠른 설정

-
- - -
-
+ {/* 탭 */} +
+ +
- {/* 하단 안내 */} -
-

- 설정은 자동으로 저장됩니다 -

+ {/* 탭 컨텐츠 */} +
+ {activeTab === 'display' ? ( + + ) : checklistManagement ? ( + + ) : ( +
+ 점검표 관리 데이터를 불러오는 중... +
+ )} +
+ + {/* 하단 안내 (화면 설정 탭일 때만) */} + {activeTab === 'display' && ( +
+

+ 설정은 자동으로 저장됩니다 +

+
+ )} +
+
+ ); +} + +// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) ===== + +interface DisplaySettingsContentProps { + settings: AuditDisplaySettings; + onToggle: (key: keyof AuditDisplaySettings) => void; + onSettingsChange: (settings: AuditDisplaySettings) => void; +} + +function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) { + return ( +
+ {/* 레이아웃 섹션 */} +
+

레이아웃

+
+ onToggle('showProgressBar')} + /> + onToggle('showDocumentViewer')} + /> + onToggle('showDocumentSection')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 점검표 섹션 */} +
+

점검표 옵션

+
+ onToggle('showCompletedItems')} + /> + onToggle('expandAllCategories')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 빠른 설정 */} +
+

빠른 설정

+
+ +
); } +// ===== 공통 설정 행 ===== + interface SettingRowProps { label: string; description: string; @@ -203,4 +312,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) { 화면 설정 ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx b/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx new file mode 100644 index 00000000..3003c2cd --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/ChecklistTemplateEditor.tsx @@ -0,0 +1,449 @@ +'use client'; + +import React, { useState } from 'react'; +import { + ChevronUp, + ChevronDown, + Pencil, + Trash2, + Plus, + Check, + X, + ChevronRight, + Save, + RotateCcw, + Loader2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ChecklistCategory, ChecklistSubItem } from '../types'; + +interface ChecklistTemplateEditorProps { + categories: ChecklistCategory[]; + hasChanges: boolean; + saving: boolean; + loading?: boolean; + error?: string | null; + // 카테고리 + onAddCategory: () => void; + onUpdateCategoryTitle: (categoryId: string, title: string) => void; + onDeleteCategory: (categoryId: string) => void; + onMoveCategoryUp: (index: number) => void; + onMoveCategoryDown: (index: number) => void; + // 하위 항목 + onAddSubItem: (categoryId: string) => void; + onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void; + onDeleteSubItem: (categoryId: string, subItemId: string) => void; + onMoveSubItemUp: (categoryId: string, index: number) => void; + onMoveSubItemDown: (categoryId: string, index: number) => void; + // 저장/초기화 + onSave: () => void; + onReset: () => void; +} + +export function ChecklistTemplateEditor({ + categories, + hasChanges, + saving, + loading, + error, + onAddCategory, + onUpdateCategoryTitle, + onDeleteCategory, + onMoveCategoryUp, + onMoveCategoryDown, + onAddSubItem, + onUpdateSubItemName, + onDeleteSubItem, + onMoveSubItemUp, + onMoveSubItemDown, + onSave, + onReset, +}: ChecklistTemplateEditorProps) { + const [expandedCategories, setExpandedCategories] = useState>( + new Set(categories.map(c => c.id)) + ); + + const toggleExpand = (categoryId: string) => { + setExpandedCategories(prev => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + }; + + if (loading) { + return ( +
+ + 점검표 로딩 중... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* 카테고리 목록 */} +
+ {categories.map((category, catIdx) => ( + toggleExpand(category.id)} + onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)} + onDelete={() => onDeleteCategory(category.id)} + onMoveUp={() => onMoveCategoryUp(catIdx)} + onMoveDown={() => onMoveCategoryDown(catIdx)} + onAddSubItem={() => onAddSubItem(category.id)} + onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)} + onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)} + onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)} + onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)} + /> + ))} +
+ + {/* 카테고리 추가 */} + + + {/* 저장/초기화 */} +
+ + +
+ + {hasChanges && ( +

+ 저장하지 않은 변경사항이 있습니다 +

+ )} +
+ ); +} + +// ===== 카테고리 편집 ===== + +interface CategoryEditorProps { + category: ChecklistCategory; + index: number; + isFirst: boolean; + isLast: boolean; + isExpanded: boolean; + onToggleExpand: () => void; + onUpdateTitle: (title: string) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onAddSubItem: () => void; + onUpdateSubItemName: (subItemId: string, name: string) => void; + onDeleteSubItem: (subItemId: string) => void; + onMoveSubItemUp: (index: number) => void; + onMoveSubItemDown: (index: number) => void; +} + +function CategoryEditor({ + category, + index, + isFirst, + isLast, + isExpanded, + onToggleExpand, + onUpdateTitle, + onDelete, + onMoveUp, + onMoveDown, + onAddSubItem, + onUpdateSubItemName, + onDeleteSubItem, + onMoveSubItemUp, + onMoveSubItemDown, +}: CategoryEditorProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(category.title); + + const handleSaveTitle = () => { + const trimmed = editValue.trim(); + if (trimmed) { + onUpdateTitle(trimmed); + } else { + setEditValue(category.title); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSaveTitle(); + if (e.key === 'Escape') { + setEditValue(category.title); + setEditing(false); + } + }; + + return ( +
+ {/* 카테고리 헤더 */} +
+ {/* 순서 변경 */} +
+ + +
+ + {/* 펼치기/접기 */} + + + {/* 제목 */} + {editing ? ( +
+ setEditValue(e.target.value)} + onBlur={handleSaveTitle} + onKeyDown={handleKeyDown} + className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400" + autoFocus + /> + + +
+ ) : ( + + {index + 1}. {category.title} + + )} + + {/* 항목 수 */} + + {category.subItems.length} + + + {/* 편집/삭제 */} + {!editing && ( + <> + + + + )} +
+ + {/* 하위 항목 */} + {isExpanded && ( +
+ {category.subItems.map((subItem, subIdx) => ( + onUpdateSubItemName(subItem.id, name)} + onDelete={() => onDeleteSubItem(subItem.id)} + onMoveUp={() => onMoveSubItemUp(subIdx)} + onMoveDown={() => onMoveSubItemDown(subIdx)} + /> + ))} + + {/* 항목 추가 */} + +
+ )} +
+ ); +} + +// ===== 하위 항목 편집 ===== + +interface SubItemEditorProps { + subItem: ChecklistSubItem; + index: number; + isFirst: boolean; + isLast: boolean; + onUpdateName: (name: string) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function SubItemEditor({ + subItem, + index, + isFirst, + isLast, + onUpdateName, + onDelete, + onMoveUp, + onMoveDown, +}: SubItemEditorProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(subItem.name); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed) { + onUpdateName(trimmed); + } else { + setEditValue(subItem.name); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') { + setEditValue(subItem.name); + setEditing(false); + } + }; + + return ( +
+ {/* 순서 변경 */} +
+ + +
+ + {/* 이름 */} + {editing ? ( +
+ setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400" + autoFocus + /> + +
+ ) : ( + { setEditValue(subItem.name); setEditing(true); }} + > + {subItem.name} + + )} + + {/* 편집/삭제 */} + {!editing && ( + <> + + + + )} +
+ ); +} + diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx index 6c4f5500..53281801 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -50,6 +50,13 @@ export function Day1ChecklistPanel({ }).filter((cat): cat is ChecklistCategory => cat !== null); }, [categories, searchTerm]); + // categories 로드 완료 시 모두 펼치기 + React.useEffect(() => { + if (categories.length > 0) { + setExpandedCategories(new Set(categories.map(c => c.id))); + } + }, [categories]); + // 검색 시 모든 카테고리 펼치기 React.useEffect(() => { if (searchTerm.trim()) { @@ -123,7 +130,7 @@ export function Day1ChecklistPanel({ 검색 결과가 없습니다
) : ( - filteredCategories.map((category, categoryIndex) => { + filteredCategories.map((category, _categoryIndex) => { const isExpanded = expandedCategories.has(category.id); const progress = getCategoryProgress(category); const allCompleted = progress.completed === progress.total; diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx index 81e8b588..ec4cba17 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx @@ -1,27 +1,45 @@ 'use client'; -import React from 'react'; -import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react'; +import React, { useState, useRef, useCallback } from 'react'; +import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import type { Day1CheckItem, StandardDocument } from '../types'; +import { toast } from 'sonner'; +import type { Day1CheckItem, TemplateDocument } from '../types'; + +const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp'; +const ACCEPTED_MIME = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/haansofthwp', +]; +const MAX_FILE_SIZE_MB = 20; interface Day1DocumentSectionProps { checkItem: Day1CheckItem | null; - selectedDocumentId: string | null; - onDocumentSelect: (documentId: string) => void; onConfirmComplete: () => void; isCompleted: boolean; isMock?: boolean; + onFileUpload?: (subItemId: string, file: File) => Promise; + uploadedFiles?: TemplateDocument[]; + onFileDelete?: (fileId: number) => void; + onFileSelect?: (file: TemplateDocument) => void; + selectedFileId?: number | null; } export function Day1DocumentSection({ checkItem, - selectedDocumentId, - onDocumentSelect, onConfirmComplete, isCompleted, isMock, + onFileUpload, + uploadedFiles = [], + onFileDelete, + onFileSelect, + selectedFileId, }: Day1DocumentSectionProps) { if (!checkItem) { return ( @@ -56,19 +74,23 @@ export function Day1DocumentSection({

{checkItem.description}

- {/* 기준 문서 목록 */} + {/* 관련 기준 문서 */}
관련 기준 문서
-
- {checkItem.standardDocuments.map((doc) => ( - onDocumentSelect(doc.id)} +
+ {uploadedFiles.map((file) => ( + onFileSelect(file) : undefined} + onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined} /> ))}
+ onFileUpload(checkItem.subItemId, file) : undefined} + />
{/* 확인 버튼 */} @@ -100,67 +122,200 @@ export function Day1DocumentSection({ ); } -interface DocumentRowProps { - document: StandardDocument; - isSelected: boolean; - onSelect: () => void; +// ===== 파일 업로드 영역 ===== + +interface DocumentUploadAreaProps { + onUpload?: (file: File) => Promise; } -function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) { +function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) { + const [isDragging, setIsDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [pendingFile, setPendingFile] = useState(null); + const fileInputRef = useRef(null); + + const validateFile = useCallback((file: File): string | null => { + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > MAX_FILE_SIZE_MB) { + return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`; + } + // 확장자 체크 + const ext = file.name.split('.').pop()?.toLowerCase(); + const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp']; + if (!ext || !allowed.includes(ext)) { + return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.'; + } + return null; + }, []); + + const handleFile = useCallback((file: File) => { + const error = validateFile(file); + if (error) { + toast.error(error); + return; + } + setPendingFile(file); + }, [validateFile]); + + const handleConfirmUpload = useCallback(async () => { + if (!pendingFile || !onUpload) return; + setUploading(true); + try { + const success = await onUpload(pendingFile); + if (success) { + toast.success(`${pendingFile.name} 업로드 완료`); + setPendingFile(null); + } + } finally { + setUploading(false); + } + }, [pendingFile, onUpload]); + + const handleCancelUpload = useCallback(() => { + setPendingFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + }; + + // 선택된 파일 미리보기 + if (pendingFile) { + const ext = pendingFile.name.split('.').pop()?.toLowerCase(); + const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1); + return ( +
+
+
+ +
+
+

{pendingFile.name}

+

{sizeMB} MB

+
+ +
+
+ + +
+
+ ); + } + + return ( + <> + +
{ e.preventDefault(); e.stopPropagation(); setIsDragging(true); }} + onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + className={cn( + 'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors', + isDragging + ? 'border-blue-400 bg-blue-50' + : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50' + )} + > + + + 파일 업로드 (PDF, Excel, Word, HWP) + +
+ + ); +} + +// ===== 업로드된 파일 행 ===== + +interface UploadedFileRowProps { + file: TemplateDocument; + isSelected?: boolean; + onSelect?: () => void; + onDelete?: () => void; +} + +function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) { + const ext = file.displayName.split('.').pop()?.toLowerCase(); + const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1); + return (
- {/* 아이콘 */}
- +
- - {/* 문서 정보 */}
-

{document.title}

-

- {document.version !== '-' && {document.version}} - {document.date} -

+

{file.displayName}

+

{sizeMB} MB

- - {/* 액션 버튼 */} -
+ {onDelete && ( - -
+ )}
); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx index 871b6b72..cdd49f5d 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx @@ -3,14 +3,120 @@ import React from 'react'; import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { StandardDocument } from '../types'; +import type { StandardDocument, TemplateDocument } from '../types'; interface Day1DocumentViewerProps { document: StandardDocument | null; + uploadedFile?: TemplateDocument | null; isMock?: boolean; } -export function Day1DocumentViewer({ document, isMock }: Day1DocumentViewerProps) { +export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) { + // 업로드된 파일이 선택된 경우 + if (uploadedFile) { + const isPdf = uploadedFile.mimeType === 'application/pdf'; + const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`; + const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`; + + // Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작) + const isLocalhost = typeof window !== 'undefined' && ( + window.location.hostname === 'localhost' || + window.location.hostname.endsWith('.sam.kr') + ); + const publicFileUrl = typeof window !== 'undefined' + ? `${window.location.origin}${viewUrl}` + : ''; + const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`; + + const isOfficeDoc = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ].includes(uploadedFile.mimeType); + + return ( +
+ {/* 헤더 */} +
+
+
+ +
+
+

+ {uploadedFile.displayName} +

+

+ {(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB +

+
+
+ +
+ + {/* 문서 미리보기 */} +
+ {isPdf ? ( +