feat(WEB): 중간검사 성적서 편집 모드 및 저장 기능 추가

- 3개 검사 콘텐츠 컴포넌트에 forwardRef + useImperativeHandle 추가 (getInspectionData 노출)
- InspectionReportModal에 readOnly prop, 저장 버튼, ref 연결 추가
- saveInspectionData 서버 액션 추가 (POST /api/v1/work-orders/{id}/inspection)
- 작업자 화면에서 readOnly={false} 전달 (편집+저장 가능)
- 작업지시 관리에서는 readOnly 기본값(true)으로 읽기 전용 유지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-30 09:32:04 +09:00
parent 3fc63d0b3e
commit 8a5cbde5ef
8 changed files with 181 additions and 357 deletions

View File

@@ -624,6 +624,48 @@ export async function updateWorkOrderItemStatus(
}
}
// ===== 중간검사 데이터 저장 =====
export async function saveInspectionData(
workOrderId: string,
processType: string,
data: unknown
): Promise<{ success: boolean; error?: string }> {
try {
console.log('[WorkOrderActions] POST inspection data:', { workOrderId, processType });
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/inspection`,
{
method: 'POST',
body: JSON.stringify({
process_type: processType,
inspection_data: data,
}),
}
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
console.log('[WorkOrderActions] POST inspection response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '검사 데이터 저장에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] saveInspectionData error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 수주 목록 조회 (작업지시 생성용) =====
export interface SalesOrderForWorkOrder {
id: number;

View File

@@ -14,9 +14,13 @@
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import type { WorkOrder } from '../types';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
interface BendingInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
@@ -98,7 +102,7 @@ const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'w
},
];
export function BendingInspectionContent({ data: order, readOnly = false }: BendingInspectionContentProps) {
export const BendingInspectionContent = forwardRef<InspectionContentRef, BendingInspectionContentProps>(function BendingInspectionContent({ data: order, readOnly = false }, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
@@ -166,6 +170,27 @@ export function BendingInspectionContent({ data: order, readOnly = false }: Bend
return null;
}, [products, getProductJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
products: products.map(p => ({
id: p.id,
category: p.category,
productName: p.productName,
productType: p.productType,
bendingStatus: p.bendingStatus,
lengthMeasured: p.lengthMeasured,
widthMeasured: p.widthMeasured,
gapPoints: p.gapPoints.map(gp => ({
point: gp.point,
designValue: gp.designValue,
measured: gp.measured,
})),
})),
inadequateContent,
overallResult,
}),
}), [products, inadequateContent, overallResult]);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
// 전체 행 수 계산 (간격 포인트 수 합계)
@@ -478,4 +503,4 @@ export function BendingInspectionContent({ data: order, readOnly = false }: Bend
</table>
</div>
);
}
});

View File

@@ -9,14 +9,17 @@
* - bending: BendingInspectionContent
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../actions';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getWorkOrderById, saveInspectionData } from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import { ScreenInspectionContent } from './ScreenInspectionContent';
import { SlatInspectionContent } from './SlatInspectionContent';
import { BendingInspectionContent } from './BendingInspectionContent';
import type { InspectionContentRef } from './ScreenInspectionContent';
const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
@@ -29,6 +32,7 @@ interface InspectionReportModalProps {
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
readOnly?: boolean;
}
export function InspectionReportModal({
@@ -36,10 +40,13 @@ export function InspectionReportModal({
onOpenChange,
workOrderId,
processType = 'screen',
readOnly = true,
}: InspectionReportModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentRef = useRef<InspectionContentRef>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType: ProcessType): WorkOrder => ({
@@ -112,6 +119,25 @@ export function InspectionReportModal({
}
}, [open, workOrderId, processType]);
const handleSave = useCallback(async () => {
if (!workOrderId || !contentRef.current) return;
const data = contentRef.current.getInspectionData();
setIsSaving(true);
try {
const result = await saveInspectionData(workOrderId, processType, data);
if (result.success) {
toast.success('검사 데이터가 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [workOrderId, processType]);
if (!workOrderId) return null;
const processLabel = PROCESS_LABELS[processType] || '스크린';
@@ -122,16 +148,27 @@ export function InspectionReportModal({
switch (processType) {
case 'screen':
return <ScreenInspectionContent data={order} />;
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
case 'slat':
return <SlatInspectionContent data={order} />;
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
case 'bending':
return <BendingInspectionContent data={order} />;
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
default:
return <ScreenInspectionContent data={order} />;
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
}
};
const toolbarExtra = !readOnly ? (
<Button onClick={handleSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
</Button>
) : undefined;
return (
<DocumentViewer
title="중간검사 성적서"
@@ -139,6 +176,7 @@ export function InspectionReportModal({
preset="inspection"
open={open}
onOpenChange={onOpenChange}
toolbarExtra={toolbarExtra}
>
{isLoading ? (
<div className="flex items-center justify-center h-64 bg-white">

View File

@@ -13,9 +13,13 @@
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import type { WorkOrder } from '../types';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
interface ScreenInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
@@ -39,7 +43,7 @@ interface InspectionRow {
const DEFAULT_ROW_COUNT = 6;
export function ScreenInspectionContent({ data: order, readOnly = false }: ScreenInspectionContentProps) {
export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenInspectionContentProps>(function ScreenInspectionContent({ data: order, readOnly = false }, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
@@ -115,6 +119,22 @@ export function ScreenInspectionContent({ data: order, readOnly = false }: Scree
return null;
}, [rows, getRowJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
rows: rows.map(row => ({
id: row.id,
processStatus: row.processStatus,
sewingStatus: row.sewingStatus,
assemblyStatus: row.assemblyStatus,
lengthMeasured: row.lengthMeasured,
widthMeasured: row.widthMeasured,
gapResult: row.gapResult,
})),
inadequateContent,
overallResult,
}),
}), [rows, inadequateContent, overallResult]);
// 체크박스 렌더 (양호/불량)
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
@@ -380,4 +400,4 @@ export function ScreenInspectionContent({ data: order, readOnly = false }: Scree
</table>
</div>
);
}
});

View File

@@ -14,9 +14,13 @@
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import type { WorkOrder } from '../types';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
interface SlatInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
@@ -38,7 +42,7 @@ interface InspectionRow {
const DEFAULT_ROW_COUNT = 6;
export function SlatInspectionContent({ data: order, readOnly = false }: SlatInspectionContentProps) {
export const SlatInspectionContent = forwardRef<InspectionContentRef, SlatInspectionContentProps>(function SlatInspectionContent({ data: order, readOnly = false }, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
@@ -100,6 +104,21 @@ export function SlatInspectionContent({ data: order, readOnly = false }: SlatIns
return null;
}, [rows, getRowJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
rows: rows.map(row => ({
id: row.id,
processStatus: row.processStatus,
assemblyStatus: row.assemblyStatus,
height1Measured: row.height1Measured,
height2Measured: row.height2Measured,
lengthMeasured: row.lengthMeasured,
})),
inadequateContent,
overallResult,
}),
}), [rows, inadequateContent, overallResult]);
// 체크박스 렌더 (양호/불량)
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
@@ -339,4 +358,4 @@ export function SlatInspectionContent({ data: order, readOnly = false }: SlatIns
</table>
</div>
);
}
});

View File

@@ -7,6 +7,7 @@ export { BendingWorkLogContent } from './BendingWorkLogContent';
export { ScreenInspectionContent } from './ScreenInspectionContent';
export { SlatInspectionContent } from './SlatInspectionContent';
export { BendingInspectionContent } from './BendingInspectionContent';
export type { InspectionContentRef } from './ScreenInspectionContent';
// 모달
export { InspectionReportModal } from './InspectionReportModal';

View File

@@ -705,6 +705,7 @@ export default function WorkerScreen() {
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
readOnly={false}
/>
<IssueReportModal