feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화

- 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 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-30 14:16:17 +09:00
parent a486977b80
commit 3ef9570f3b
27 changed files with 554 additions and 451 deletions

View File

@@ -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: [
{

View File

@@ -104,8 +104,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{data.cards[0]?.label || '현금성 자산 합계'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[0]?.amount || 0)}
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[0]?.amount || 0)}
</span>
{data.cards[0]?.changeRate && (
<span
style={{ color: data.cards[0].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[0].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[0].changeRate}
</span>
)}
</div>
</div>
@@ -123,10 +136,23 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{data.cards[1]?.label || '외국환(USD) 합계'}
</span>
</div>
<div style={{ color: '#0f172a' }} className="text-2xl font-bold">
{data.cards[1]?.currency === 'USD'
? formatUSD(data.cards[1]?.amount || 0)
: formatBillion(data.cards[1]?.amount || 0)}
<div className="flex items-end gap-2">
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{data.cards[1]?.currency === 'USD'
? formatUSD(data.cards[1]?.amount || 0)
: formatBillion(data.cards[1]?.amount || 0)}
</span>
{data.cards[1]?.changeRate && (
<span
style={{ color: data.cards[1].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[1].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[1].changeRate}
</span>
)}
</div>
</div>
@@ -148,10 +174,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[2]?.amount || 0)}
</span>
<span style={{ color: '#16a34a' }} className="flex items-center text-xs font-medium mb-1">
<ArrowUpRight className="h-3 w-3" />
+12%
</span>
{data.cards[2]?.changeRate && (
<span
style={{ color: data.cards[2].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[2].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[2].changeRate}
</span>
)}
</div>
</div>
@@ -173,10 +206,17 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
<span style={{ color: '#0f172a' }} className="text-2xl font-bold">
{formatBillion(data.cards[3]?.amount || 0)}
</span>
<span style={{ color: '#e11d48' }} className="flex items-center text-xs font-medium mb-1">
<ArrowDownRight className="h-3 w-3" />
-8%
</span>
{data.cards[3]?.changeRate && (
<span
style={{ color: data.cards[3].changeDirection === 'up' ? '#ef4444' : '#3b82f6' }}
className="flex items-center text-xs font-medium mb-1"
>
{data.cards[3].changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{data.cards[3].changeRate}
</span>
)}
</div>
</div>
</div>

View File

@@ -43,6 +43,8 @@ export interface AmountCard {
unit?: string; // 건, 원 등
currency?: 'KRW' | 'USD'; // 통화 (기본: KRW)
isHighlighted?: boolean; // 빨간색 강조
changeRate?: string; // 어제 대비 변동률 (예: "+5.2%", "-3.1%")
changeDirection?: 'up' | 'down'; // 변동 방향
}
// 오늘의 이슈 항목 (카드 형태 - 현황판용)

View File

@@ -150,15 +150,15 @@ export function ApprovalLine({
{/* 부서 행 (선택적) */}
{showDepartment && (
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
{departmentLabels.writer}
</td>
{is4Col && (
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
{departmentLabels.reviewer}
</td>
)}
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px] whitespace-nowrap">
{departmentLabels.approver}
</td>
</tr>

View File

@@ -96,16 +96,16 @@ export function ConstructionApprovalTable({
{/* 부서 행 */}
<tr>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
{approvers.writer?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
{approvers.approver1?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
{approvers.approver2?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
{approvers.approver3?.department || '부서명'}
</td>
</tr>

View File

@@ -213,7 +213,7 @@ export function DocumentHeader({
<span className="text-2xl font-bold">{logo.text}</span>
)}
{logo.subtext && (
<span className="text-xs text-gray-500">{logo.subtext}</span>
<span className="text-xs text-gray-500 whitespace-nowrap">{logo.subtext}</span>
)}
</div>
)}

View File

@@ -74,7 +74,7 @@ export function QualityApprovalTable({
</tbody>
</table>
{reportDate && (
<div className="text-xs text-right mt-1">: {reportDate}</div>
<div className="text-xs text-right mt-1 whitespace-nowrap">: {reportDate}</div>
)}
</div>
);

View File

@@ -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}
>
<Printer size={14} />
<span className="hidden sm:inline"></span>
{loadingAction === 'print' ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Printer size={14} />
)}
<span className="hidden sm:inline">{loadingAction === 'print' ? '인쇄 중...' : '인쇄'}</span>
</Button>
);
@@ -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}
>
<Download size={14} />
<span className="hidden sm:inline"></span>
{loadingAction === 'download' ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
<span className="hidden sm:inline">{loadingAction === 'download' ? '다운로드 중...' : '다운로드'}</span>
</Button>
);
@@ -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}
>
<Download size={14} />
<span className="hidden sm:inline">PDF</span>
{loadingAction === 'pdf' ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
<span className="hidden sm:inline">{loadingAction === 'pdf' ? 'PDF 생성 중...' : 'PDF'}</span>
</Button>
);

View File

@@ -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<ActionType | null>(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}
/>
{/* 콘텐츠 */}

View File

@@ -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 && (
<button
onClick={onToggleAll}
className="w-full flex items-center space-x-2 px-3 py-2 rounded-lg text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-all duration-200"
>
{expandedMenus.length > 0 ? (
<>
<ChevronsDownUp className="h-3.5 w-3.5 flex-shrink-0" />
<span> </span>
</>
) : (
<>
<ChevronsUpDown className="h-3.5 w-3.5 flex-shrink-0" />
<span> </span>
</>
)}
</button>
)}
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-3'
sidebarCollapsed ? 'space-y-1 mt-2' : 'space-y-1.5 mt-1'
}`}>
{menuItems.map((item) => (
<MenuItemComponent

View File

@@ -191,6 +191,20 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
}),
}), [products, inadequateContent, overallResult]);
// PDF 호환 체크박스 렌더
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
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<InspectionContentRef, Bending
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -224,10 +238,10 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
@@ -426,24 +440,12 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
{/* 절곡상태 - 양호/불량 체크 */}
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '양호'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(product.bendingStatus === '양호', () => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '불량'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(product.bendingStatus === '불량', () => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량'))}
</label>
</div>
@@ -484,16 +486,13 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}

View File

@@ -53,7 +53,7 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -75,10 +75,10 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>

View File

@@ -161,7 +161,7 @@ export function InspectionReportModal({
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
) : undefined;

View File

@@ -83,10 +83,19 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
));
}, [readOnly]);
// 숫자 콤마 포맷
const formatNumberWithComma = (value: string): string => {
const num = value.replace(/[^\d]/g, '');
if (!num) return '';
return Number(num).toLocaleString();
};
const handleInputChange = useCallback((rowId: number, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
// 숫자만 저장 (콤마 제거)
const numOnly = value.replace(/[^\d]/g, '');
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
row.id === rowId ? { ...row, [field]: numOnly } : row
));
}, [readOnly]);
@@ -135,28 +144,29 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
}),
}), [rows, inadequateContent, overallResult]);
// 체크박스 렌더 (양호/불량)
// PDF 호환 체크박스 렌더 (양호/불량)
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
</label>
</div>
@@ -171,7 +181,7 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -193,10 +203,10 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
@@ -332,35 +342,23 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
<input type="text" value={formatNumberWithComma(row.lengthMeasured)} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 나비 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
<input type="text" value={formatNumberWithComma(row.widthMeasured)} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</td>
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={row.gapResult === 'OK'}
onChange={() => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK')}
disabled={readOnly}
className="w-3 h-3"
/>
{renderCheckbox(row.gapResult === 'OK', () => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK'))}
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={row.gapResult === 'NG'}
onChange={() => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG')}
disabled={readOnly}
className="w-3 h-3"
/>
{renderCheckbox(row.gapResult === 'NG', () => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG'))}
NG
</label>
</div>
@@ -381,16 +379,13 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}

View File

@@ -55,7 +55,7 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps)
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -77,10 +77,10 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps)
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>

View File

@@ -83,8 +83,10 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
if (readOnly) return;
// 숫자 + 소수점만 허용
const filtered = value.replace(/[^\d.]/g, '');
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
row.id === rowId ? { ...row, [field]: filtered } : row
));
}, [readOnly]);
@@ -119,28 +121,29 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
}),
}), [rows, inadequateContent, overallResult]);
// 체크박스 렌더 (양호/불량)
// PDF 호환 체크박스 렌더 (양호/불량)
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
</label>
</div>
@@ -155,7 +158,7 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -177,10 +180,10 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
@@ -339,16 +342,13 @@ export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspec
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={2}>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}

View File

@@ -51,7 +51,7 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
@@ -73,10 +73,10 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>

View File

@@ -14,6 +14,7 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useMenuStore } from '@/store/menuStore';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
@@ -188,6 +189,7 @@ const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean
export default function WorkerScreen() {
// ===== 상태 관리 =====
const { sidebarCollapsed } = useMenuStore();
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
@@ -509,7 +511,7 @@ export default function WorkerScreen() {
return (
<PageLayout>
<div className="space-y-6">
<div className="space-y-6 pb-20">
{/* 완료 토스트 */}
{toastInfo && <CompletionToast info={toastInfo} />}
@@ -656,8 +658,8 @@ export default function WorkerScreen() {
</Tabs>
</div>
{/* 하단 고정 버튼 */}
<div className="sticky bottom-0 border-t border-gray-200 pt-4 pb-2 z-10">
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
<div className="flex gap-3">
<Button
variant="outline"