From 3ef9570f3b7a52733ef42cb9e256727920b808ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Fri, 30 Jan 2026 14:16:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20API=20=EC=9D=B8=ED=94=84=EB=9D=BC?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20CEO=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=98=84=ED=99=A9=ED=8C=90=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가 - CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장 - 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선 - 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫) - 레이아웃: Sidebar/AuthenticatedLayout 수정 - 작업자화면: WorkerScreen 수정 Co-Authored-By: Claude Opus 4.5 --- .../documents/ImportInspectionDocument.tsx | 6 +- src/app/api/proxy/[...path]/route.ts | 176 +++++------------- .../business/CEODashboard/mockData.ts | 8 +- .../sections/EnhancedSections.tsx | 68 +++++-- src/components/business/CEODashboard/types.ts | 2 + .../components/ApprovalLine.tsx | 6 +- .../components/ConstructionApprovalTable.tsx | 8 +- .../components/DocumentHeader.tsx | 2 +- .../components/QualityApprovalTable.tsx | 2 +- .../viewer/DocumentToolbar.tsx | 33 +++- .../document-system/viewer/DocumentViewer.tsx | 22 ++- src/components/layout/Sidebar.tsx | 25 ++- .../documents/BendingInspectionContent.tsx | 53 +++--- .../documents/BendingWorkLogContent.tsx | 10 +- .../documents/InspectionReportModal.tsx | 2 +- .../documents/ScreenInspectionContent.tsx | 85 ++++----- .../documents/ScreenWorkLogContent.tsx | 10 +- .../documents/SlatInspectionContent.tsx | 58 +++--- .../documents/SlatWorkLogContent.tsx | 10 +- .../production/WorkerScreen/index.tsx | 8 +- src/layouts/AuthenticatedLayout.tsx | 16 ++ src/lib/api/authenticated-fetch.ts | 95 ++++++++++ src/lib/api/dashboard/transformers.ts | 35 ++++ src/lib/api/dashboard/types.ts | 10 + src/lib/api/fetch-wrapper.ts | 125 ++++--------- src/lib/api/index.ts | 70 +++---- src/lib/api/refresh-token.ts | 60 +++--- 27 files changed, 554 insertions(+), 451 deletions(-) create mode 100644 src/lib/api/authenticated-fetch.ts diff --git a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx index 424f865e..31b6b569 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx @@ -589,8 +589,8 @@ export const ImportInspectionDocument = ({ > {opt.isSelected ? 'V' : ''} - {opt.label} - {opt.tolerance} + {opt.label} + {opt.tolerance.replace(/\n/g, ' ')} )); }; @@ -794,7 +794,7 @@ export const ImportInspectionDocument = ({
종합판정
-
{ - // FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정 - const headers: Record = { - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - 'Authorization': token ? `Bearer ${token}` : '', - }; - - // FormData가 아닌 경우에만 Content-Type 설정 - if (!isFormData) { - headers['Content-Type'] = contentType; - } - - return fetch(url.toString(), { - method, - headers, - body, - }); -} - /** * 쿠키 생성 헬퍼 함수 */ @@ -74,13 +41,13 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin cookies.push([ `access_token=${tokens.accessToken}`, 'HttpOnly', - ...(isProduction ? ['Secure'] : []), // HTTPS only in production - 'SameSite=Lax', // Lax for better compatibility (matches login route) + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); - // ✅ FCM 등에서 인증 상태 확인용 (non-HttpOnly) + // FCM 등에서 인증 상태 확인용 (non-HttpOnly) cookies.push([ 'is_authenticated=true', ...(isProduction ? ['Secure'] : []), @@ -94,8 +61,8 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin cookies.push([ `refresh_token=${tokens.refreshToken}`, 'HttpOnly', - ...(isProduction ? ['Secure'] : []), // HTTPS only in production - 'SameSite=Lax', // Lax for better compatibility (matches login route) + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', 'Max-Age=604800', // 7 days ].join('; ')); @@ -119,12 +86,6 @@ function createClearTokenCookies(): string[] { /** * Catch-all proxy handler for all HTTP methods - * - * 🔄 토큰 갱신 로직: - * 1. 현재 access_token으로 백엔드 요청 - * 2. 401 응답 시 → refresh_token으로 새 토큰 발급 - * 3. 새 토큰으로 원래 요청 재시도 - * 4. 재시도도 실패하면 → 쿠키 삭제 후 401 반환 */ async function proxyRequest( request: NextRequest, @@ -132,20 +93,9 @@ async function proxyRequest( method: string ) { try { - // 1. 🆕 미들웨어에서 전달한 새 토큰 먼저 확인 - // Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음 - // 따라서 request headers로 전달된 새 토큰을 먼저 사용 - const refreshedAccessToken = request.headers.get('x-refreshed-access-token'); - const refreshedRefreshToken = request.headers.get('x-refreshed-refresh-token'); - - // 2. HttpOnly 쿠키에서 토큰 읽기 (서버에서만 가능!) - let token = refreshedAccessToken || request.cookies.get('access_token')?.value; - const refreshToken = refreshedRefreshToken || request.cookies.get('refresh_token')?.value; - - // 디버깅: 어떤 토큰을 사용하는지 로그 - if (refreshedAccessToken) { - console.log('🔵 [PROXY] Using refreshed token from middleware headers'); - } + // 1. HttpOnly 쿠키에서 토큰 읽기 + const token = request.cookies.get('access_token')?.value; + const refreshToken = request.cookies.get('refresh_token')?.value; // 2. 백엔드 URL 구성 const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; @@ -163,26 +113,19 @@ async function proxyRequest( if (contentType.includes('application/json')) { body = await request.text(); console.log('🔵 [PROXY] Request:', method, url.toString()); - console.log('🔵 [PROXY] Request Body:', body); // 디버깅용 + console.log('🔵 [PROXY] Request Body:', body); } else if (contentType.includes('multipart/form-data')) { - // multipart/form-data 처리: FormData를 그대로 전달 console.log('📎 [PROXY] Processing multipart/form-data request'); isFormData = true; - // 원본 요청의 FormData 읽기 const originalFormData = await request.formData(); - - // 새 FormData 생성 (백엔드 전송용) const newFormData = new FormData(); - // 모든 필드 복사 for (const [key, value] of originalFormData.entries()) { if (value instanceof File) { - // File 객체는 그대로 추가 newFormData.append(key, value, value.name); console.log(`📎 [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`); } else { - // 일반 필드 newFormData.append(key, value); console.log(`📎 [PROXY] Form field: ${key} = ${value}`); } @@ -195,69 +138,42 @@ async function proxyRequest( console.log('🔵 [PROXY] Request:', method, url.toString()); } - // 4. 백엔드로 프록시 요청 - let backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); - let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null; - - // 5. 🔄 401 응답 시 토큰 갱신 후 재시도 - if (backendResponse.status === 401 && refreshToken) { - console.log('🔄 [PROXY] Got 401, attempting token refresh...'); - - const refreshResult = await refreshAccessToken(refreshToken, 'PROXY'); - - if (refreshResult.success && refreshResult.accessToken) { - console.log('✅ [PROXY] Token refreshed, retrying original request...'); - - // 새 토큰으로 원래 요청 재시도 - token = refreshResult.accessToken; - newTokens = refreshResult; - backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); - - console.log('🔵 [PROXY] Retry response status:', backendResponse.status); - } else { - // 🔄 리프레시 실패 → 다른 요청이 동시에 refresh 중일 수 있음 - // 짧은 딜레이 후 한 번 더 refresh 시도 - console.log('🔄 [PROXY] Refresh failed, waiting and retrying...'); - await new Promise(resolve => setTimeout(resolve, 500)); // 500ms 대기 - - // 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음) - const latestRefreshToken = request.cookies.get('refresh_token')?.value; - if (latestRefreshToken) { - const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY'); - - if (retryResult.success && retryResult.accessToken) { - console.log('✅ [PROXY] Retry refresh succeeded!'); - token = retryResult.accessToken; - newTokens = retryResult; - backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); - console.log('🔵 [PROXY] Retry response status:', backendResponse.status); - } - } - - // 여전히 401이면 쿠키 삭제하고 401 반환 - if (backendResponse.status === 401) { - console.warn('🔴 [PROXY] Token refresh failed after retry, clearing cookies...'); - - const clearResponse = NextResponse.json( - { error: 'Authentication failed', needsReauth: true }, - { status: 401 } - ); - - createClearTokenCookies().forEach(cookie => { - clearResponse.headers.append('Set-Cookie', cookie); - }); - - return clearResponse; - } - } + // 4. 헤더 구성 + const headers: Record = { + 'Accept': 'application/json', + 'X-API-KEY': process.env.API_KEY || '', + 'Authorization': token ? `Bearer ${token}` : '', + }; + if (!isFormData) { + headers['Content-Type'] = contentType; } - // 6. 응답 데이터 읽기 + // 5. authenticatedFetch 게이트웨이로 요청 실행 + // 401 감지 → refresh → retry 모두 게이트웨이가 처리 + const { response: backendResponse, newTokens, authFailed } = await authenticatedFetch( + url.toString(), + { method, headers, body }, + refreshToken, + 'PROXY' + ); + + // 6. 인증 실패 → 쿠키 삭제 + 401 반환 + if (authFailed) { + console.warn('🔴 [PROXY] Auth failed, clearing cookies...'); + const clearResponse = NextResponse.json( + { error: 'Authentication failed', needsReauth: true }, + { status: 401 } + ); + createClearTokenCookies().forEach(cookie => { + clearResponse.headers.append('Set-Cookie', cookie); + }); + return clearResponse; + } + + // 7. 응답 처리 (바이너리 vs 텍스트/JSON) console.log('🔵 [PROXY] Response status:', backendResponse.status); const responseContentType = backendResponse.headers.get('content-type') || 'application/json'; - // 7. 바이너리 파일 vs 텍스트/JSON 구분 - // 파일 다운로드 (PDF, 이미지, 등)는 바이너리로 처리해야 손상되지 않음 const isBinaryResponse = responseContentType.includes('application/pdf') || responseContentType.includes('application/octet-stream') || @@ -270,7 +186,6 @@ async function proxyRequest( let clientResponse: NextResponse; if (isBinaryResponse) { - // 바이너리 파일: arrayBuffer로 읽어서 그대로 전달 console.log('📄 [PROXY] Binary response detected:', responseContentType); const binaryData = await backendResponse.arrayBuffer(); @@ -283,7 +198,6 @@ async function proxyRequest( }, }); } else { - // JSON/텍스트: text로 읽어서 전달 const responseData = await backendResponse.text(); clientResponse = new NextResponse(responseData, { @@ -295,7 +209,7 @@ async function proxyRequest( } // 8. 토큰이 갱신되었으면 새 쿠키 설정 - if (newTokens && newTokens.accessToken) { + if (newTokens?.accessToken) { createTokenCookies(newTokens).forEach(cookie => { clientResponse.headers.append('Set-Cookie', cookie); }); diff --git a/src/components/business/CEODashboard/mockData.ts b/src/components/business/CEODashboard/mockData.ts index 31924065..18bfbd7c 100644 --- a/src/components/business/CEODashboard/mockData.ts +++ b/src/components/business/CEODashboard/mockData.ts @@ -11,10 +11,10 @@ export const mockData: CEODashboardData = { dailyReport: { date: '2026년 1월 5일 월요일', cards: [ - { id: 'dr1', label: '현금성 자산 합계', amount: 3050000000 }, - { id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD' }, - { id: 'dr3', label: '입금 합계', amount: 1020000000 }, - { id: 'dr4', label: '출금 합계', amount: 350000000 }, + { id: 'dr1', label: '현금성 자산 합계', amount: 3050000000, changeRate: '+5.2%', changeDirection: 'up' }, + { id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD', changeRate: '+2.1%', changeDirection: 'up' }, + { id: 'dr3', label: '입금 합계', amount: 1020000000, changeRate: '+12.0%', changeDirection: 'up' }, + { id: 'dr4', label: '출금 합계', amount: 350000000, changeRate: '-8.0%', changeDirection: 'down' }, ], checkPoints: [ { diff --git a/src/components/business/CEODashboard/sections/EnhancedSections.tsx b/src/components/business/CEODashboard/sections/EnhancedSections.tsx index 941679ef..2ca5e0f6 100644 --- a/src/components/business/CEODashboard/sections/EnhancedSections.tsx +++ b/src/components/business/CEODashboard/sections/EnhancedSections.tsx @@ -104,8 +104,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor {data.cards[0]?.label || '현금성 자산 합계'}
-
- {formatBillion(data.cards[0]?.amount || 0)} +
+ + {formatBillion(data.cards[0]?.amount || 0)} + + {data.cards[0]?.changeRate && ( + + {data.cards[0].changeDirection === 'up' + ? + : } + {data.cards[0].changeRate} + + )}
@@ -123,10 +136,23 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor {data.cards[1]?.label || '외국환(USD) 합계'}
-
- {data.cards[1]?.currency === 'USD' - ? formatUSD(data.cards[1]?.amount || 0) - : formatBillion(data.cards[1]?.amount || 0)} +
+ + {data.cards[1]?.currency === 'USD' + ? formatUSD(data.cards[1]?.amount || 0) + : formatBillion(data.cards[1]?.amount || 0)} + + {data.cards[1]?.changeRate && ( + + {data.cards[1].changeDirection === 'up' + ? + : } + {data.cards[1].changeRate} + + )}
@@ -148,10 +174,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor {formatBillion(data.cards[2]?.amount || 0)} - - - +12% - + {data.cards[2]?.changeRate && ( + + {data.cards[2].changeDirection === 'up' + ? + : } + {data.cards[2].changeRate} + + )}
@@ -173,10 +206,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor {formatBillion(data.cards[3]?.amount || 0)} - - - -8% - + {data.cards[3]?.changeRate && ( + + {data.cards[3].changeDirection === 'up' + ? + : } + {data.cards[3].changeRate} + + )} diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index 03b5a472..66693dd6 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -43,6 +43,8 @@ export interface AmountCard { unit?: string; // 건, 원 등 currency?: 'KRW' | 'USD'; // 통화 (기본: KRW) isHighlighted?: boolean; // 빨간색 강조 + changeRate?: string; // 어제 대비 변동률 (예: "+5.2%", "-3.1%") + changeDirection?: 'up' | 'down'; // 변동 방향 } // 오늘의 이슈 항목 (카드 형태 - 현황판용) diff --git a/src/components/document-system/components/ApprovalLine.tsx b/src/components/document-system/components/ApprovalLine.tsx index a3552582..4e4ea753 100644 --- a/src/components/document-system/components/ApprovalLine.tsx +++ b/src/components/document-system/components/ApprovalLine.tsx @@ -150,15 +150,15 @@ export function ApprovalLine({ {/* 부서 행 (선택적) */} {showDepartment && ( - + {departmentLabels.writer} {is4Col && ( - + {departmentLabels.reviewer} )} - + {departmentLabels.approver} diff --git a/src/components/document-system/components/ConstructionApprovalTable.tsx b/src/components/document-system/components/ConstructionApprovalTable.tsx index e88d2ca1..5df7652d 100644 --- a/src/components/document-system/components/ConstructionApprovalTable.tsx +++ b/src/components/document-system/components/ConstructionApprovalTable.tsx @@ -96,16 +96,16 @@ export function ConstructionApprovalTable({ {/* 부서 행 */} - + {approvers.writer?.department || '부서명'} - + {approvers.approver1?.department || '부서명'} - + {approvers.approver2?.department || '부서명'} - + {approvers.approver3?.department || '부서명'} diff --git a/src/components/document-system/components/DocumentHeader.tsx b/src/components/document-system/components/DocumentHeader.tsx index ce717df5..12433a95 100644 --- a/src/components/document-system/components/DocumentHeader.tsx +++ b/src/components/document-system/components/DocumentHeader.tsx @@ -213,7 +213,7 @@ export function DocumentHeader({ {logo.text} )} {logo.subtext && ( - {logo.subtext} + {logo.subtext} )} )} diff --git a/src/components/document-system/components/QualityApprovalTable.tsx b/src/components/document-system/components/QualityApprovalTable.tsx index 881e8c46..59ae76ea 100644 --- a/src/components/document-system/components/QualityApprovalTable.tsx +++ b/src/components/document-system/components/QualityApprovalTable.tsx @@ -74,7 +74,7 @@ export function QualityApprovalTable({ {reportDate && ( -
접고일자: {reportDate}
+
접고일자: {reportDate}
)} ); diff --git a/src/components/document-system/viewer/DocumentToolbar.tsx b/src/components/document-system/viewer/DocumentToolbar.tsx index 105b86a4..d825204c 100644 --- a/src/components/document-system/viewer/DocumentToolbar.tsx +++ b/src/components/document-system/viewer/DocumentToolbar.tsx @@ -16,6 +16,7 @@ import { FileOutput, Mail, MessageCircle, + Loader2, } from 'lucide-react'; import { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; @@ -41,6 +42,9 @@ interface DocumentToolbarProps { onFax?: () => void; onKakao?: () => void; toolbarExtra?: ReactNode; + + // 로딩 상태 + loadingAction?: ActionType | null; } /** @@ -65,8 +69,10 @@ export function DocumentToolbar({ onFax, onKakao, toolbarExtra, + loadingAction, }: DocumentToolbarProps) { const showZoomControls = features.zoom !== false; + const isAnyLoading = !!loadingAction; // 액션 버튼 렌더링 const renderActionButton = (action: ActionType) => { @@ -80,9 +86,14 @@ export function DocumentToolbar({ size="sm" className="h-8 gap-1 text-xs px-2 sm:px-3" onClick={onPrint} + disabled={isAnyLoading} > - - 인쇄 + {loadingAction === 'print' ? ( + + ) : ( + + )} + {loadingAction === 'print' ? '인쇄 중...' : '인쇄'} ); @@ -94,9 +105,14 @@ export function DocumentToolbar({ size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3" onClick={onDownload} + disabled={isAnyLoading} > - - 다운로드 + {loadingAction === 'download' ? ( + + ) : ( + + )} + {loadingAction === 'download' ? '다운로드 중...' : '다운로드'} ); @@ -196,9 +212,14 @@ export function DocumentToolbar({ size="sm" className="h-8 bg-red-600 hover:bg-red-700 text-white gap-1 text-xs px-2 sm:px-3" onClick={onPdf} + disabled={isAnyLoading} > - - PDF + {loadingAction === 'pdf' ? ( + + ) : ( + + )} + {loadingAction === 'pdf' ? 'PDF 생성 중...' : 'PDF'} ); diff --git a/src/components/document-system/viewer/DocumentViewer.tsx b/src/components/document-system/viewer/DocumentViewer.tsx index acb7b9ad..db1407fc 100644 --- a/src/components/document-system/viewer/DocumentViewer.tsx +++ b/src/components/document-system/viewer/DocumentViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, ReactNode, useCallback } from 'react'; +import React, { useEffect, useState, ReactNode, useCallback } from 'react'; import { Dialog, DialogContent, @@ -102,11 +102,23 @@ export function DocumentViewer({ const features: DocumentFeatures = merged.features; const actions: ActionType[] = merged.actions; + // 로딩 상태 + const [loadingAction, setLoadingAction] = useState(null); + // 줌 훅 - const zoom = useZoom(); + const zoomBase = useZoom(); // 드래그 훅 - const drag = useDrag({ enabled: features.drag !== false && zoom.zoom > 100 }); + const drag = useDrag({ enabled: features.drag !== false && zoomBase.zoom > 100 }); + + // 맞춤 클릭 시 드래그 위치도 함께 리셋 + const zoom = { + ...zoomBase, + zoomReset: () => { + zoomBase.zoomReset(); + drag.resetPosition(); + }, + }; // 모달 열릴 때 상태 초기화 useEffect(() => { @@ -182,6 +194,7 @@ export function DocumentViewer({ } try { + setLoadingAction('pdf'); toast.loading('PDF 생성 중...', { id: 'pdf-generating' }); // print-area 영역 찾기 @@ -234,6 +247,8 @@ export function DocumentViewer({ } catch (error) { console.error('PDF 생성 오류:', error); toast.error('PDF 생성 중 오류가 발생했습니다.', { id: 'pdf-generating' }); + } finally { + setLoadingAction(null); } }, [onPdf, title, pdfMeta]); @@ -301,6 +316,7 @@ export function DocumentViewer({ onFax={onFax} onKakao={onKakao} toolbarExtra={toolbarExtra} + loadingAction={loadingAction} /> {/* 콘텐츠 */} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a1dd2796..dd8fdd70 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Circle } from 'lucide-react'; +import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react'; import type { MenuItem } from '@/store/menuStore'; import { useEffect, useRef } from 'react'; @@ -10,6 +10,7 @@ interface SidebarProps { isMobile: boolean; onMenuClick: (menuId: string, path: string) => void; onToggleSubmenu: (menuId: string) => void; + onToggleAll?: () => void; onCloseMobileSidebar?: () => void; } @@ -245,6 +246,7 @@ export default function Sidebar({ isMobile, onMenuClick, onToggleSubmenu, + onToggleAll, onCloseMobileSidebar, }: SidebarProps) { // 활성 메뉴 자동 스크롤을 위한 ref @@ -274,8 +276,27 @@ export default function Sidebar({ sidebarCollapsed ? 'px-2 py-2' : 'px-3 py-4 md:px-4 md:py-4' }`} > + {/* 전체 열기/닫기 토글 버튼 - 사이드바 펼침 상태에서만 표시 */} + {!sidebarCollapsed && onToggleAll && ( + + )}
{menuItems.map((item) => ( void) => ( + !readOnly && onClick()} + role="checkbox" + aria-checked={checked} + > + {checked ? '✓' : ''} + + ); + const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs'; // 전체 행 수 계산 (간격 포인트 수 합계) @@ -202,7 +216,7 @@ export const BendingInspectionContent = forwardRef

중간검사성적서 (절곡)

-

+

문서번호: {documentNo} | 작성일자: {fullDate}

@@ -224,10 +238,10 @@ export const BendingInspectionContent = forwardRef이름 - 부서명 - 부서명 - 부서명 - 부서명 + 부서명 + 부서명 + 부서명 + 부서명 @@ -426,24 +440,12 @@ export const BendingInspectionContent = forwardRef
-
@@ -484,16 +486,13 @@ export const BendingInspectionContent = forwardRef - 부적합 내용 - + 부적합 내용 +