feat: [생산지시] 목록/상세 API 연동 + 작업자 화면 개선

- ProductionOrders 목록/상세 페이지 API 연동
- 절곡 중간검사 입력 모달 (7개 제품 항목 통합)
- 자재투입 다중 BOM 그룹 LOT 독립 관리
- 작업자 화면 제품명 productCode만 표시
- BOM 공정 분류 접이식 카드 UI
- 검사성적서 TemplateInspectionContent API 연동
This commit is contained in:
2026-03-07 03:02:52 +09:00
parent c150d80725
commit 8b6da749a9
24 changed files with 1688 additions and 1156 deletions

View File

@@ -0,0 +1,115 @@
'use server';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
ApiProductionOrder,
ApiProductionOrderDetail,
ProductionOrder,
ProductionOrderDetail,
ProductionOrderStats,
ProductionOrderListParams,
ProductionStatus,
} from './types';
// ===== 변환 함수 =====
function formatDateOnly(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
// ISO "2026-02-21T18:12:31.000000Z" 또는 "2026-02-22 03:12:31" 형식 모두 지원
return dateStr.split(/[T ]/)[0];
}
function transformApiToFrontend(data: ApiProductionOrder): ProductionOrder {
return {
id: String(data.id),
orderNumber: data.order_no,
siteName: data.site_name || '',
clientName: data.client_name || data.client?.name || '',
quantity: parseFloat(String(data.quantity)) || 0,
nodeCount: data.node_count || data.nodes_count || 0,
deliveryDate: data.delivery_date || '',
productionOrderedAt: formatDateOnly(data.production_ordered_at),
productionStatus: data.production_status,
workOrderCount: data.work_orders_count,
workOrderProgress: {
total: data.work_order_progress?.total || 0,
completed: data.work_order_progress?.completed || 0,
inProgress: data.work_order_progress?.in_progress || 0,
},
};
}
function transformDetailApiToFrontend(data: ApiProductionOrderDetail): ProductionOrderDetail {
const order = transformApiToFrontend(data.order);
return {
...order,
productionOrderedAt: formatDateOnly(data.production_ordered_at) || order.productionOrderedAt,
productionStatus: data.production_status || order.productionStatus,
nodeCount: data.node_count || order.nodeCount,
workOrderProgress: {
total: data.work_order_progress?.total || 0,
completed: data.work_order_progress?.completed || 0,
inProgress: data.work_order_progress?.in_progress || 0,
},
workOrders: (data.work_orders || []).map((wo) => ({
id: wo.id,
workOrderNo: wo.work_order_no,
processName: wo.process_name,
quantity: wo.quantity,
status: wo.status,
assignees: wo.assignees || [],
})),
bomProcessGroups: (data.bom_process_groups || []).map((group) => ({
processName: group.process_name,
sizeSpec: group.size_spec,
items: (group.items || []).map((item) => ({
id: item.id,
itemCode: item.item_code,
itemName: item.item_name,
spec: item.spec || '',
unit: item.unit || '',
quantity: item.quantity ?? 0,
unitPrice: item.unit_price ?? 0,
totalPrice: item.total_price ?? 0,
nodeName: item.node_name || '',
})),
})),
};
}
// ===== Server Actions =====
// 목록 조회
export async function getProductionOrders(params: ProductionOrderListParams) {
return executePaginatedAction<ApiProductionOrder, ProductionOrder>({
url: buildApiUrl('/api/v1/production-orders', {
search: params.search,
production_status: params.productionStatus,
sort_by: params.sortBy,
sort_dir: params.sortDir,
page: params.page,
per_page: params.perPage,
}),
transform: transformApiToFrontend,
errorMessage: '생산지시 목록 조회에 실패했습니다.',
});
}
// 상태별 통계
export async function getProductionOrderStats() {
return executeServerAction<ProductionOrderStats>({
url: buildApiUrl('/api/v1/production-orders/stats'),
errorMessage: '생산지시 통계 조회에 실패했습니다.',
});
}
// 상세 조회
export async function getProductionOrderDetail(orderId: string) {
return executeServerAction<ApiProductionOrderDetail, ProductionOrderDetail>({
url: buildApiUrl(`/api/v1/production-orders/${orderId}`),
transform: transformDetailApiToFrontend,
errorMessage: '생산지시 상세 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,141 @@
// 생산지시 상태 (프론트 탭용)
export type ProductionStatus = 'waiting' | 'in_production' | 'completed';
// API 응답 타입 (snake_case)
export interface ApiProductionOrder {
id: number;
order_no: string;
site_name: string;
client_name: string;
quantity: number;
node_count: number;
delivery_date: string | null;
status_code: string;
production_ordered_at: string | null;
production_status: ProductionStatus;
work_orders_count: number;
nodes_count: number;
work_order_progress: {
total: number;
completed: number;
in_progress: number;
};
client?: {
id: number;
name: string;
};
}
// 프론트 타입 (camelCase)
export interface ProductionOrder {
id: string;
orderNumber: string;
siteName: string;
clientName: string;
quantity: number;
nodeCount: number;
deliveryDate: string;
productionOrderedAt: string;
productionStatus: ProductionStatus;
workOrderCount: number;
workOrderProgress: {
total: number;
completed: number;
inProgress: number;
};
}
// 생산지시 통계
export interface ProductionOrderStats {
total: number;
waiting: number;
in_production: number;
completed: number;
}
// 생산지시 상세 API 응답
export interface ApiProductionOrderDetail {
order: ApiProductionOrder;
production_ordered_at: string | null;
production_status: ProductionStatus;
node_count: number;
work_order_progress: {
total: number;
completed: number;
in_progress: number;
};
work_orders: ApiProductionWorkOrder[];
bom_process_groups: ApiBomProcessGroup[];
}
// 상세 내 작업지시 정보
export interface ApiProductionWorkOrder {
id: number;
work_order_no: string;
process_name: string;
quantity: number;
status: string;
assignees: string[];
}
// BOM 공정 분류
export interface ApiBomProcessGroup {
process_name: string;
size_spec?: string;
items: ApiBomItem[];
}
export interface ApiBomItem {
id: number | null;
item_code: string;
item_name: string;
spec: string;
unit: string;
quantity: number;
unit_price: number;
total_price: number;
node_name: string;
}
// 프론트 상세 타입
export interface ProductionOrderDetail extends ProductionOrder {
workOrders: ProductionWorkOrder[];
bomProcessGroups: BomProcessGroup[];
}
export interface ProductionWorkOrder {
id: number;
workOrderNo: string;
processName: string;
quantity: number;
status: string;
assignees: string[];
}
export interface BomProcessGroup {
processName: string;
sizeSpec?: string;
items: BomItem[];
}
export interface BomItem {
id: number | null;
itemCode: string;
itemName: string;
spec: string;
unit: string;
quantity: number;
unitPrice: number;
totalPrice: number;
nodeName: string;
}
// 조회 파라미터
export interface ProductionOrderListParams {
search?: string;
productionStatus?: ProductionStatus;
sortBy?: string;
sortDir?: 'asc' | 'desc';
page?: number;
perPage?: number;
}

View File

@@ -8,7 +8,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
import { FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
@@ -22,14 +22,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createWorkOrder, getProcessOptions, searchItemsForWorkOrder, type ProcessOption, type ManualItemOption } from './actions';
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
import { type SalesOrder } from './types';
import { workOrderCreateConfig } from './workOrderConfig';
import { useDevFill } from '@/components/dev';
@@ -44,20 +43,6 @@ interface ManualItem {
unit: string;
}
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
selectedOrder: '수주',
client: '발주처',
projectName: '현장명',
processId: '공정',
shipmentDate: '출고예정일',
};
type RegistrationMode = 'linked' | 'manual';
interface FormData {
@@ -102,7 +87,7 @@ export function WorkOrderCreate() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
@@ -114,6 +99,17 @@ export function WorkOrderCreate() {
const [isSearchingItems, setIsSearchingItems] = useState(false);
const [showItemSearch, setShowItemSearch] = useState(false);
// 필드 에러 클리어 헬퍼
const clearFieldError = useCallback((field: string) => {
if (validationErrors[field]) {
setValidationErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
}, [validationErrors]);
// 공정 옵션 로드
useEffect(() => {
async function loadProcessOptions() {
@@ -173,6 +169,7 @@ export function WorkOrderCreate() {
orderNo: order.orderNo,
itemCount: order.itemCount,
});
clearFieldError('selectedOrder');
};
// 수주 해제
@@ -217,6 +214,7 @@ export function WorkOrderCreate() {
setShowItemSearch(false);
setItemSearchQuery('');
setItemSearchResults([]);
clearFieldError('items');
};
// 품목 수량 변경
@@ -232,7 +230,7 @@ export function WorkOrderCreate() {
// 폼 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
const errors: Record<string, string> = {};
if (mode === 'linked') {
if (!formData.selectedOrder) {
@@ -261,8 +259,8 @@ export function WorkOrderCreate() {
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
const firstError = Object.values(errors)[0];
toast.error(firstError);
return { success: false, error: '' };
}
@@ -318,35 +316,6 @@ export function WorkOrderCreate() {
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 등록 방식 */}
<section className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
@@ -381,7 +350,7 @@ export function WorkOrderCreate() {
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
{!formData.selectedOrder ? (
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
<div className={`flex items-center justify-between p-4 bg-white border rounded-lg ${validationErrors.selectedOrder ? 'border-red-500' : ''}`}>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
@@ -448,6 +417,7 @@ export function WorkOrderCreate() {
</div>
</div>
)}
{validationErrors.selectedOrder && <p className="text-sm text-red-500 mt-1">{validationErrors.selectedOrder}</p>}
</section>
)}
@@ -459,21 +429,29 @@ export function WorkOrderCreate() {
<Label> *</Label>
<Input
value={formData.client}
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, client: e.target.value });
clearFieldError('client');
}}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
disabled={mode === 'linked'}
className="bg-white"
className={`bg-white ${validationErrors.client ? 'border-red-500' : ''}`}
/>
{validationErrors.client && <p className="text-sm text-red-500">{validationErrors.client}</p>}
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, projectName: e.target.value });
clearFieldError('projectName');
}}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
disabled={mode === 'linked'}
className="bg-white"
className={`bg-white ${validationErrors.projectName ? 'border-red-500' : ''}`}
/>
{validationErrors.projectName && <p className="text-sm text-red-500">{validationErrors.projectName}</p>}
</div>
<div className="space-y-2">
<Label></Label>
@@ -506,10 +484,13 @@ export function WorkOrderCreate() {
<Label> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
onValueChange={(value) => {
setFormData({ ...formData, processId: parseInt(value) });
clearFieldError('processId');
}}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
</SelectTrigger>
<SelectContent>
@@ -520,6 +501,7 @@ export function WorkOrderCreate() {
))}
</SelectContent>
</Select>
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
<p className="text-xs text-muted-foreground">
: {getSelectedProcessCode()}
</p>
@@ -529,8 +511,13 @@ export function WorkOrderCreate() {
<Label> *</Label>
<DatePicker
value={formData.shipmentDate}
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
onChange={(date) => {
setFormData({ ...formData, shipmentDate: date });
clearFieldError('shipmentDate');
}}
className={validationErrors.shipmentDate ? 'border-red-500' : ''}
/>
{validationErrors.shipmentDate && <p className="text-sm text-red-500">{validationErrors.shipmentDate}</p>}
</div>
<div className="space-y-2">
@@ -717,7 +704,7 @@ export function WorkOrderCreate() {
/>
</section>
</div>
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems]);
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems, clearFieldError]);
return (
<>
@@ -751,4 +738,4 @@ export function WorkOrderCreate() {
/>
</>
);
}
}

View File

@@ -30,7 +30,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
@@ -52,17 +51,6 @@ interface EditableItem extends WorkOrderItem {
editQuantity?: number;
}
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
processId: '공정',
scheduledDate: '출고예정일',
};
interface FormData {
// 기본 정보 (읽기 전용)
client: string;
@@ -101,7 +89,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [deleteTargetItemId, setDeleteTargetItemId] = useState<string | null>(null);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
@@ -213,7 +201,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 폼 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
const errors: Record<string, string> = {};
if (!formData.processId) {
errors.processId = '공정을 선택해주세요';
@@ -226,7 +214,8 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
window.scrollTo({ top: 0, behavior: 'smooth' });
const firstError = Object.values(errors)[0];
toast.error(firstError);
return { success: false, error: '입력 정보를 확인해주세요.' };
}
@@ -344,35 +333,6 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 (기획서 4열 구성) */}
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
<h3 className="font-semibold mb-4"> </h3>
@@ -391,10 +351,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<Label className="text-sm text-muted-foreground"> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
onValueChange={(value) => {
setFormData({ ...formData, processId: parseInt(value) });
if (validationErrors.processId) {
setValidationErrors(prev => { const { processId: _, ...rest } = prev; return rest; });
}
}}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
</SelectTrigger>
<SelectContent>
@@ -405,6 +370,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
))}
</SelectContent>
</Select>
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
@@ -442,8 +408,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<Label className="text-sm text-muted-foreground"> *</Label>
<DatePicker
value={formData.scheduledDate}
onChange={(date) => setFormData({ ...formData, scheduledDate: date })}
onChange={(date) => {
setFormData({ ...formData, scheduledDate: date });
if (validationErrors.scheduledDate) {
setValidationErrors(prev => { const { scheduledDate: _, ...rest } = prev; return rest; });
}
}}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
@@ -671,4 +644,4 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
/>
</>
);
}
}

View File

@@ -857,6 +857,7 @@ export async function saveInspectionDocument(
title?: string;
data: Record<string, unknown>[];
approvers?: { role_name: string; user_id?: number }[];
rendered_html?: string;
}
): Promise<{
success: boolean;
@@ -921,6 +922,34 @@ export async function resolveInspectionDocument(
}
}
// ===== 문서 스냅샷 저장 (Lazy Snapshot) =====
export async function patchDocumentSnapshot(
documentId: number,
renderedHtml: string
): Promise<{ success: boolean; error?: string }> {
try {
const { response, error } = await serverFetch(
buildApiUrl(`/api/v1/documents/${documentId}/snapshot`),
{ method: 'PATCH', body: JSON.stringify({ rendered_html: renderedHtml }) }
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '스냅샷 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] patchDocumentSnapshot error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 문서 결재 상신 =====
export async function submitDocumentForApproval(
documentId: number

View File

@@ -213,6 +213,15 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
...p,
bendingStatus: bendingStatusValue,
})));
} else if (itemData.judgment) {
// 이전 형식 호환: products/bendingStatus 없이 judgment만 있는 경우
const inferredStatus: CheckStatus = itemData.judgment === 'pass' ? '양호' : itemData.judgment === 'fail' ? '불량' : null;
if (inferredStatus) {
setProducts(prev => prev.map(p => ({
...p,
bendingStatus: inferredStatus,
})));
}
}
// 부적합 내용 로드

View File

@@ -24,6 +24,7 @@ import {
saveInspectionDocument,
resolveInspectionDocument,
submitDocumentForApproval,
patchDocumentSnapshot,
} from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import type { InspectionReportData, InspectionReportNodeGroup } from '../actions';
@@ -164,6 +165,7 @@ export function InspectionReportModal({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentRef = useRef<InspectionContentRef>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// API에서 로딩된 검사 데이터 (props 없을 때 fallback)
const [apiWorkItems, setApiWorkItems] = useState<WorkItemData[] | null>(null);
@@ -183,6 +185,8 @@ export function InspectionReportModal({
const [savedDocumentId, setSavedDocumentId] = useState<number | null>(null);
const [savedDocumentStatus, setSavedDocumentStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Lazy Snapshot 대상 문서 ID (rendered_html이 없는 문서)
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
// props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨)
// ★ 반드시 workItems와 inspectionDataMap을 같은 소스에서 가져와야 key 포맷이 일치함
@@ -296,7 +300,8 @@ export function InspectionReportModal({
// 4) 기존 문서의 document_data EAV 레코드 + ID/상태 추출
if (resolveResult?.success && resolveResult.data) {
const existingDoc = (resolveResult.data as Record<string, unknown>).existing_document as
const resolveData = resolveResult.data as Record<string, unknown>;
const existingDoc = resolveData.existing_document as
| { id?: number; status?: string; data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> }
| null;
if (existingDoc?.data && existingDoc.data.length > 0) {
@@ -307,10 +312,13 @@ export function InspectionReportModal({
// 문서 ID/상태 저장 (결재 상신용)
setSavedDocumentId(existingDoc?.id ?? null);
setSavedDocumentStatus(existingDoc?.status ?? null);
// Lazy Snapshot 대상 문서 ID
setSnapshotDocumentId((resolveData.snapshot_document_id as number) ?? null);
} else {
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
setSnapshotDocumentId(null);
}
})
.catch(() => {
@@ -328,10 +336,30 @@ export function InspectionReportModal({
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
setSnapshotDocumentId(null);
setError(null);
}
}, [open, workOrderId, processType, templateData]);
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
useEffect(() => {
if (!snapshotDocumentId || isLoading || !order) return;
// 콘텐츠 렌더링 대기 후 캡처
const timer = setTimeout(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
if (result.success) {
setSnapshotDocumentId(null); // 저장 완료 → 재실행 방지
}
});
}
}, 500); // DOM 렌더링 완료 대기
return () => clearTimeout(timer);
}, [snapshotDocumentId, isLoading, order]);
// 템플릿 결정: prop 우선, 없으면 자체 로딩 결과 사용
const resolvedTemplateData = templateData || selfTemplateData;
const activeTemplate = resolvedTemplateData?.has_template ? resolvedTemplateData.template : null;
@@ -341,6 +369,8 @@ export function InspectionReportModal({
if (!workOrderId || !contentRef.current) return;
const data = contentRef.current.getInspectionData();
// HTML 스냅샷 캡처 (MNG 출력용)
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
setIsSaving(true);
try {
// 템플릿 모드: Document 기반 저장 (정규화 형식)
@@ -359,6 +389,7 @@ export function InspectionReportModal({
step_id: activeStepId ?? undefined,
title: activeTemplate.title || activeTemplate.name,
data: inspData.records,
rendered_html: renderedHtml,
});
if (result.success) {
toast.success('검사 문서가 저장되었습니다.');
@@ -530,7 +561,9 @@ export function InspectionReportModal({
)}
</div>
)}
{renderContent()}
<div ref={contentWrapperRef}>
{renderContent()}
</div>
</>
)}
</DocumentViewer>

View File

@@ -624,6 +624,124 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentRecords, isBending, bendingProducts]);
// ===== Bending: inspectionDataMap의 products 배열에서 셀 값 복원 =====
// InspectionInputModal이 저장한 products 배열 → bending 셀 키로 매핑
// ★ inspectionDataMap의 products가 있으면 documentRecords(EAV)보다 우선
// (입력 모달에서 방금 저장한 신규 데이터가 이전 문서 데이터보다 최신)
useEffect(() => {
if (!isBending || !inspectionDataMap || !workItems || bendingProducts.length === 0) return;
// inspectionDataMap에서 products 배열 찾기
type SavedProduct = {
id: string;
bendingStatus: string | null;
lengthMeasured: string;
widthMeasured: string;
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
};
let savedProducts: SavedProduct[] | undefined;
for (const wi of workItems) {
const d = inspectionDataMap.get(wi.id) as Record<string, unknown> | undefined;
if (d?.products && Array.isArray(d.products)) {
savedProducts = d.products as SavedProduct[];
break;
}
}
if (!savedProducts || savedProducts.length === 0) return;
const initial: Record<string, CellValue> = {};
// 컬럼 분류
const checkColId = template.columns.find(c => c.column_type === 'check')?.id;
const complexCols = template.columns.filter(c =>
c.column_type === 'complex' && c.id !== gapColumnId
);
// 각 template bendingProduct → 저장된 product 매핑
bendingProducts.forEach((bp, productIdx) => {
// 1. ID 정규화 매칭 (guide-rail-wall ↔ guide_rail_wall)
const normalizedBpId = bp.id.replace(/[-_]/g, '').toLowerCase();
let matched = savedProducts!.find(sp =>
sp.id.replace(/[-_]/g, '').toLowerCase() === normalizedBpId
);
// 2. 이름 키워드 매칭
if (!matched) {
const bpKey = `${bp.productName}${bp.productType}`.replace(/\s/g, '').toLowerCase();
matched = savedProducts!.find(sp => {
const spId = sp.id.toLowerCase();
if (bpKey.includes('가이드레일') && bpKey.includes('벽면') && spId.includes('guide') && spId.includes('wall')) return true;
if (bpKey.includes('가이드레일') && bpKey.includes('측면') && spId.includes('guide') && spId.includes('side')) return true;
if (bpKey.includes('케이스') && spId.includes('case')) return true;
if (bpKey.includes('하단마감') && (spId.includes('bottom-finish') || spId.includes('bottom_bar'))) return true;
if (bpKey.includes('연기차단') && bpKey.includes('w50') && spId.includes('w50')) return true;
if (bpKey.includes('연기차단') && bpKey.includes('w80') && spId.includes('w80')) return true;
return false;
});
}
// 3. 인덱스 폴백
if (!matched && productIdx < savedProducts!.length) {
matched = savedProducts![productIdx];
}
if (!matched) return;
// check 컬럼 (절곡상태)
if (checkColId) {
const cellKey = `b-${productIdx}-${checkColId}`;
if (matched.bendingStatus === '양호') {
initial[cellKey] = { status: 'good' };
} else if (matched.bendingStatus === '불량') {
initial[cellKey] = { status: 'bad' };
}
}
// 간격 컬럼
if (gapColumnId && matched.gapPoints) {
matched.gapPoints.forEach((gp, pointIdx) => {
if (gp.measured) {
const cellKey = `b-${productIdx}-p${pointIdx}-${gapColumnId}`;
initial[cellKey] = { measurements: [gp.measured, '', ''] };
}
});
}
// complex 컬럼 (길이/너비)
// bending 렌더링은 measurements[si] (si = sub_label raw index)를 읽으므로
// 측정값 sub_label의 실제 si 위치에 값을 넣어야 함
for (const col of complexCols) {
const label = col.label.trim();
const cellKey = `b-${productIdx}-${col.id}`;
// 측정값 sub_label의 si 인덱스 찾기
let measurementSi = 0;
if (col.sub_labels) {
for (let si = 0; si < col.sub_labels.length; si++) {
const sl = col.sub_labels[si].toLowerCase();
if (!sl.includes('도면') && !sl.includes('기준')) {
measurementSi = si;
break;
}
}
}
const measurements: [string, string, string] = ['', '', ''];
if (label.includes('길이') && matched.lengthMeasured) {
measurements[measurementSi] = matched.lengthMeasured;
initial[cellKey] = { measurements };
} else if ((label.includes('너비') || label.includes('폭') || label.includes('높이')) && matched.widthMeasured) {
measurements[measurementSi] = matched.widthMeasured;
initial[cellKey] = { measurements };
}
}
});
if (Object.keys(initial).length > 0) {
setCellValues(prev => ({ ...prev, ...initial }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isBending, inspectionDataMap, workItems, bendingProducts, template.columns, gapColumnId]);
const updateCell = (key: string, update: Partial<CellValue>) => {
setCellValues(prev => ({
...prev,

View File

@@ -8,7 +8,7 @@
*/
import type { BendingInfoExtended, MaterialMapping } from './types';
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
interface BottomBarSectionProps {
bendingInfo: BendingInfoExtended;
@@ -17,7 +17,7 @@ interface BottomBarSectionProps {
}
export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) {
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping);
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping, bendingInfo.productCode);
if (rows.length === 0) return null;
return (
@@ -57,7 +57,7 @@ export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSe
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>

View File

@@ -8,7 +8,7 @@
*/
import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types';
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
interface GuideRailSectionProps {
bendingInfo: BendingInfoExtended;
@@ -63,7 +63,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize, lotNoMap }: {
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
@@ -81,11 +81,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
const productCode = bendingInfo.productCode;
const wallRows = wall
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping, productCode)
: [];
const sideRows = side
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping, productCode)
: [];
if (wallRows.length === 0 && sideRows.length === 0) return null;

View File

@@ -8,7 +8,7 @@
*/
import type { BendingInfoExtended, ShutterBoxData } from './types';
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
interface ShutterBoxSectionProps {
bendingInfo: BendingInfoExtended;
@@ -75,9 +75,10 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
{(() => {
const dimNum = parseInt(row.dimension);
if (!isNaN(dimNum) && !row.dimension.includes('*')) {
return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-';
return lookupLotNo(lotNoMap, row.lotPrefix, dimNum);
}
return '-';
// 치수형(1219*539 등)도 prefix-only fallback
return lookupLotNo(lotNoMap, row.lotPrefix);
})()}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>

View File

@@ -9,7 +9,7 @@
*/
import type { BendingInfoExtended } from './types';
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
interface SmokeBarrierSectionProps {
bendingInfo: BendingInfoExtended;
@@ -57,7 +57,14 @@ export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSecti
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotCode}`] || '-'}
{(() => {
// 정확 매칭 (GI-83, GI-54 등)
const exact = lotNoMap?.[`BD-${row.lotCode}`];
if (exact) return exact;
// Fallback: GI prefix로 검색
const prefix = row.lotCode.split('-')[0];
return lookupLotNo(lotNoMap, prefix, row.length);
})()}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>

View File

@@ -181,22 +181,29 @@ export function buildWallGuideRailRows(
lengthData: LengthQuantity[],
baseDimension: string,
mapping: MaterialMapping,
productCode?: string,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
const isSteel = codePrefix === 'KTE';
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
const finishPrefix = isSUS ? 'RS' : 'RE';
const bodyPrefix = isSteel ? 'RT' : 'RM';
for (const ld of lengthData) {
if (ld.quantity <= 0) continue;
// ①②마감재
const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish,
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
});
// ③본체
const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial,
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
@@ -216,7 +223,7 @@ export function buildWallGuideRailRows(
if (mapping.guideRailExtraFinish) {
const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish,
partName: '⑥별도마감', lotPrefix: 'YY', material: mapping.guideRailExtraFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100,
});
}
@@ -244,21 +251,27 @@ export function buildSideGuideRailRows(
lengthData: LengthQuantity[],
baseDimension: string,
mapping: MaterialMapping,
productCode?: string,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
const isSteel = codePrefix === 'KTE';
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
const finishPrefix = isSUS ? 'SS' : 'SE';
const bodyPrefix = isSteel ? 'ST' : 'SM';
for (const ld of lengthData) {
if (ld.quantity <= 0) continue;
const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length);
rows.push({
partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish,
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
});
const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length);
rows.push({
partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial,
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
@@ -295,9 +308,12 @@ export function buildSideGuideRailRows(
export function buildBottomBarRows(
bottomBar: BendingInfoExtended['bottomBar'],
mapping: MaterialMapping,
productCode?: string,
): BottomBarPartRow[] {
const rows: BottomBarPartRow[] = [];
const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE';
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
const isSteel = codePrefix === 'KTE';
const lotPrefix = isSteel ? 'TS' : (mapping.bottomBarFinish.includes('SUS') ? 'BS' : 'BE');
// ①하단마감재 - 3000mm
if (bottomBar.length3000Qty > 0) {
@@ -321,7 +337,7 @@ export function buildBottomBarRows(
// ④별도마감재 (extraFinish !== '없음' 일 때만)
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE';
const extraLotPrefix = 'YY';
if (bottomBar.length3000Qty > 0) {
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000);
@@ -570,3 +586,33 @@ export function fmtWeight(v: number): string {
export function lengthToCode(lengthMm: number): string {
return getSLengthCode(lengthMm, '') || String(lengthMm);
}
/**
* lotNoMap에서 LOT NO 조회
*
* bending_info의 길이와 실제 자재투입 길이가 다를 수 있으므로,
* 정확한 매칭 실패 시 prefix만으로 fallback 매칭합니다.
*
* @param lotNoMap - item_code → lot_no 매핑 (e.g. 'BD-SS-35' → 'INIT-260221-BDSS35')
* @param prefix - 세부품목 prefix (e.g. 'SS', 'SM', 'BS')
* @param length - 길이(mm), optional
*/
export function lookupLotNo(
lotNoMap: Record<string, string> | undefined,
prefix: string,
length?: number,
): string {
if (!lotNoMap) return '-';
// 1. 정확한 매칭 (prefix + lengthCode)
if (length) {
const code = lengthToCode(length);
const exact = lotNoMap[`BD-${prefix}-${code}`];
if (exact) return exact;
}
// 2. Fallback: prefix만으로 매칭 (첫 번째 일치 항목)
const prefixKey = `BD-${prefix}-`;
const fallbackKey = Object.keys(lotNoMap).find(k => k.startsWith(prefixKey));
return fallbackKey ? lotNoMap[fallbackKey] : '-';
}

View File

@@ -11,7 +11,7 @@
* - bending_wip: 재고생산(재공품) 중간검사
*/
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -24,6 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { InspectionTemplateData, InspectionTemplateSectionItem } from './types';
import { formatNumber } from '@/lib/utils/amount';
import { getInspectionConfig } from '@/components/production/WorkOrders/actions';
import type { InspectionConfigData } from '@/components/production/WorkOrders/actions';
// 중간검사 공정 타입
export type InspectionProcessType =
@@ -71,6 +73,88 @@ interface InspectionInputModalProps {
templateData?: InspectionTemplateData;
/** 작업 아이템의 실제 치수 (reference_attribute 연동용) */
workItemDimensions?: { width?: number; height?: number };
/** 작업지시 ID (절곡 gap_points API 조회용) */
workOrderId?: string;
}
// ===== 절곡 7개 제품 검사 항목 (BendingInspectionContent의 INITIAL_PRODUCTS와 동일 구조) =====
interface BendingGapPointDef {
point: string;
design: string;
}
interface BendingProductDef {
id: string;
label: string;
lengthDesign: string;
widthDesign: string;
gapPoints: BendingGapPointDef[];
}
const BENDING_PRODUCTS: BendingProductDef[] = [
{
id: 'guide-rail-wall', label: '가이드레일 (벽면형)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '30' }, { point: '②', design: '80' },
{ point: '③', design: '45' }, { point: '④', design: '40' }, { point: '⑤', design: '34' },
],
},
{
id: 'guide-rail-side', label: '가이드레일 (측면형)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '28' }, { point: '②', design: '75' },
{ point: '③', design: '42' }, { point: '④', design: '38' }, { point: '⑤', design: '32' },
],
},
{
id: 'case', label: '케이스 (500X380)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '380' }, { point: '②', design: '50' },
{ point: '③', design: '240' }, { point: '④', design: '50' },
],
},
{
id: 'bottom-finish', label: '하단마감재 (60X40)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '②', design: '60' }, { point: '②', design: '64' },
],
},
{
id: 'bottom-l-bar', label: '하단L-BAR (17X60)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '17' },
],
},
{
id: 'smoke-w50', label: '연기차단재 (W50)', lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', design: '50' }, { point: '②', design: '12' },
],
},
{
id: 'smoke-w80', label: '연기차단재 (W80)', lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', design: '80' }, { point: '②', design: '12' },
],
},
];
interface BendingProductState {
id: string;
bendingStatus: 'good' | 'bad' | null;
lengthMeasured: string;
widthMeasured: string;
gapMeasured: string[];
}
function createInitialBendingProducts(): BendingProductState[] {
return BENDING_PRODUCTS.map(p => ({
id: p.id,
bendingStatus: null,
lengthMeasured: '',
widthMeasured: '',
gapMeasured: p.gapPoints.map(() => ''),
}));
}
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
@@ -461,9 +545,11 @@ export function InspectionInputModal({
onComplete,
templateData,
workItemDimensions,
workOrderId,
}: InspectionInputModalProps) {
// 템플릿 모드 여부
const useTemplateMode = !!(templateData?.has_template && templateData.template);
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
const [formData, setFormData] = useState<InspectionData>({
productName,
@@ -475,11 +561,72 @@ export function InspectionInputModal({
// 동적 폼 값 (템플릿 모드용)
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
// 절곡용 간격 포인트 초기화
// 이전 형식 데이터 로드 시 auto-judgment가 judgment를 덮어쓰지 않도록 보호
const skipAutoJudgmentRef = useRef(false);
// 절곡용 간격 포인트 초기화 (레거시 — bending_wip 등에서 사용)
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
Array(5).fill(null).map(() => ({ left: null, right: null }))
);
// 절곡 API 제품 정의 (gap_points 동적 로딩)
const [apiProductDefs, setApiProductDefs] = useState<BendingProductDef[] | null>(null);
const effectiveProductDefs = apiProductDefs || BENDING_PRODUCTS;
// 절곡 7개 제품별 상태 (bending 전용)
const [bendingProducts, setBendingProducts] = useState<BendingProductState[]>(createInitialBendingProducts);
// API에서 절곡 제품 gap_points 동적 로딩
useEffect(() => {
if (!open || processType !== 'bending' || !workOrderId) return;
let cancelled = false;
getInspectionConfig(workOrderId).then(result => {
if (cancelled) return;
if (result.success && result.data?.items?.length) {
const displayMap: Record<string, { label: string; len: string; wid: string }> = {
guide_rail_wall: { label: '가이드레일 (벽면형)', len: '3000', wid: 'N/A' },
guide_rail_side: { label: '가이드레일 (측면형)', len: '3000', wid: 'N/A' },
case_box: { label: '케이스 (500X380)', len: '3000', wid: 'N/A' },
bottom_bar: { label: '하단마감재 (60X40)', len: '3000', wid: 'N/A' },
bottom_l_bar: { label: '하단L-BAR (17X60)', len: '3000', wid: 'N/A' },
smoke_w50: { label: '연기차단재 (W50)', len: '3000', wid: '' },
smoke_w80: { label: '연기차단재 (W80)', len: '3000', wid: '' },
};
const defs: BendingProductDef[] = result.data.items.map(item => {
const d = displayMap[item.id] || { label: item.name, len: '-', wid: 'N/A' };
return {
id: item.id,
label: d.label,
lengthDesign: d.len,
widthDesign: d.wid,
gapPoints: item.gap_points.map(gp => ({ point: gp.point, design: gp.design_value })),
};
});
setApiProductDefs(defs);
}
});
return () => { cancelled = true; };
}, [open, processType, workOrderId]);
// API 제품 정의 로딩 시 bendingProducts 갱신 (gap 개수 동기화)
useEffect(() => {
if (!apiProductDefs || processType !== 'bending') return;
setBendingProducts(prev => {
return apiProductDefs.map((def, idx) => {
// 기존 입력값 보존 (ID 매칭 또는 인덱스 폴백)
const existing = prev.find(p => p.id === def.id || p.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, ''))
|| (idx < prev.length ? prev[idx] : undefined);
return {
id: def.id,
bendingStatus: existing?.bendingStatus ?? null,
lengthMeasured: existing?.lengthMeasured ?? '',
widthMeasured: existing?.widthMeasured ?? '',
gapMeasured: def.gapPoints.map((_, gi) => existing?.gapMeasured?.[gi] ?? ''),
};
});
});
}, [apiProductDefs, processType]);
useEffect(() => {
if (open) {
// initialData가 있으면 기존 저장 데이터로 복원
@@ -495,6 +642,46 @@ export function InspectionInputModal({
} else {
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
}
// 절곡 제품별 데이터 복원
const savedProducts = (initialData as unknown as Record<string, unknown>).products as Array<{
id: string;
bendingStatus: string;
lengthMeasured: string;
widthMeasured: string;
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
}> | undefined;
if (savedProducts && Array.isArray(savedProducts)) {
setBendingProducts(effectiveProductDefs.map((def, idx) => {
const saved = savedProducts.find(sp =>
sp.id === def.id || sp.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, '')
) || (idx < savedProducts.length ? savedProducts[idx] : undefined);
if (!saved) return { id: def.id, bendingStatus: null, lengthMeasured: '', widthMeasured: '', gapMeasured: def.gapPoints.map(() => '') };
return {
id: def.id,
bendingStatus: saved.bendingStatus === '양호' ? 'good' : saved.bendingStatus === '불량' ? 'bad' : (saved.bendingStatus as 'good' | 'bad' | null),
lengthMeasured: saved.lengthMeasured || '',
widthMeasured: saved.widthMeasured || '',
gapMeasured: def.gapPoints.map((_, gi) => saved.gapPoints?.[gi]?.measured || ''),
};
}));
} else if (processType === 'bending' && initialData.judgment) {
// 이전 형식 데이터 호환: products 배열 없이 저장된 경우
// judgment 값으로 제품별 상태 추론 (pass → 전체 양호)
const restoredStatus: 'good' | 'bad' | null =
initialData.judgment === 'pass' ? 'good' : initialData.judgment === 'fail' ? 'bad' : null;
setBendingProducts(effectiveProductDefs.map(def => ({
id: def.id,
bendingStatus: restoredStatus,
lengthMeasured: '',
widthMeasured: '',
gapMeasured: def.gapPoints.map(() => ''),
})));
// 이전 형식은 lengthMeasured가 없어 autoJudgment가 null이 되므로
// 로드된 judgment를 덮어쓰지 않도록 보호
skipAutoJudgmentRef.current = true;
} else {
setBendingProducts(createInitialBendingProducts());
}
// 동적 폼 값 복원 (템플릿 기반 검사 데이터)
if (initialData.templateValues) {
setDynamicFormValues(initialData.templateValues);
@@ -554,20 +741,37 @@ export function InspectionInputModal({
}
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
setBendingProducts(createInitialBendingProducts());
setDynamicFormValues({});
}
}, [open, productName, specification, processType, initialData]);
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
// 자동 판정 계산 (템플릿 모드 vs 절곡 7제품 모드 vs 레거시 모드)
const autoJudgment = useMemo(() => {
if (useTemplateMode && templateData?.template) {
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
}
// 절곡 7개 제품 전용 판정
if (processType === 'bending') {
let allGood = true;
let allFilled = true;
for (const p of bendingProducts) {
if (p.bendingStatus === 'bad') return 'fail';
if (p.bendingStatus !== 'good') { allGood = false; allFilled = false; }
if (!p.lengthMeasured) allFilled = false;
}
if (allGood && allFilled) return 'pass';
return null;
}
return computeJudgment(processType, formData);
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData]);
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData, bendingProducts]);
// 판정값 자동 동기화
// 판정값 자동 동기화 (이전 형식 데이터 로드 시 첫 번째 동기화 건너뜀)
useEffect(() => {
if (skipAutoJudgmentRef.current) {
skipAutoJudgmentRef.current = false;
return;
}
setFormData((prev) => {
if (prev.judgment === autoJudgment) return prev;
return { ...prev, judgment: autoJudgment };
@@ -575,13 +779,32 @@ export function InspectionInputModal({
}, [autoJudgment]);
const handleComplete = () => {
const data: InspectionData = {
const baseData: InspectionData = {
...formData,
gapPoints: processType === 'bending' ? gapPoints : undefined,
// 동적 폼 값을 templateValues로 병합
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
};
onComplete(data);
// 절곡: products 배열을 성적서와 동일 포맷으로 저장
if (processType === 'bending') {
const products = bendingProducts.map((p, idx) => ({
id: p.id,
bendingStatus: p.bendingStatus === 'good' ? '양호' : p.bendingStatus === 'bad' ? '불량' : null,
lengthMeasured: p.lengthMeasured,
widthMeasured: p.widthMeasured,
gapPoints: (effectiveProductDefs[idx]?.gapPoints || []).map((gp, gi) => ({
point: gp.point,
designValue: gp.design,
measured: p.gapMeasured[gi] || '',
})),
}));
const data = { ...baseData, products } as unknown as InspectionData;
onComplete(data);
onOpenChange(false);
return;
}
onComplete(baseData);
onOpenChange(false);
};
@@ -866,75 +1089,96 @@ export function InspectionInputModal({
</>
)}
{/* ===== 절곡 검사 항목 ===== */}
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
{!useTemplateMode && processType === 'bending' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.bendingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (N/A)</span>
<Input
type="text"
placeholder="N/A"
value={formData.width ?? 'N/A'}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="space-y-3">
<span className="text-sm font-bold"></span>
{gapPoints.map((point, index) => (
<div key={index} className="grid grid-cols-3 gap-2 items-center">
<span className="text-gray-500 text-sm font-medium">{index + 1}</span>
<Input
type="number"
placeholder={String(30 + index * 10)}
value={point.left ?? ''}
onChange={(e) => {
const newPoints = [...gapPoints];
newPoints[index] = {
...newPoints[index],
left: e.target.value === '' ? null : parseFloat(e.target.value),
};
setGapPoints(newPoints);
}}
className="h-11 rounded-lg border-gray-300"
/>
<Input
type="number"
placeholder={String(30 + index * 10)}
value={point.right ?? ''}
onChange={(e) => {
const newPoints = [...gapPoints];
newPoints[index] = {
...newPoints[index],
right: e.target.value === '' ? null : parseFloat(e.target.value),
};
setGapPoints(newPoints);
}}
className="h-11 rounded-lg border-gray-300"
/>
<div className="space-y-4">
{effectiveProductDefs.map((productDef, pIdx) => {
const pState = bendingProducts[pIdx];
if (!pState) return null;
const updateProduct = (updates: Partial<BendingProductState>) => {
setBendingProducts(prev => prev.map((p, i) => i === pIdx ? { ...p, ...updates } : p));
};
return (
<div key={productDef.id} className={cn(pIdx > 0 && 'border-t border-gray-200 pt-4')}>
{/* 제품명 헤더 */}
<div className="mb-3">
<span className="text-sm font-bold text-gray-900">
{pIdx + 1}. {productDef.label}
</span>
</div>
{/* 절곡상태 */}
<div className="space-y-1.5 mb-3">
<span className="text-xs text-gray-500 font-medium"></span>
<StatusToggle
value={pState.bendingStatus}
onChange={(v) => updateProduct({ bendingStatus: v })}
/>
</div>
{/* 길이 / 너비 */}
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"> ({productDef.lengthDesign})</span>
<Input
type="number"
placeholder={productDef.lengthDesign}
value={pState.lengthMeasured}
onChange={(e) => updateProduct({ lengthMeasured: e.target.value })}
className="h-10 rounded-lg border-gray-300 text-sm"
/>
</div>
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"> ({productDef.widthDesign || '-'})</span>
{productDef.widthDesign === 'N/A' ? (
<Input
type="text"
value="N/A"
readOnly
className="h-10 bg-gray-100 border-gray-300 rounded-lg text-sm"
/>
) : (
<Input
type="number"
placeholder={productDef.widthDesign || '-'}
value={pState.widthMeasured}
onChange={(e) => updateProduct({ widthMeasured: e.target.value })}
className="h-10 rounded-lg border-gray-300 text-sm"
/>
)}
</div>
</div>
{/* 간격 포인트 */}
{productDef.gapPoints.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"></span>
<div className="grid grid-cols-2 gap-2">
{productDef.gapPoints.map((gp, gi) => (
<div key={gi} className="flex items-center gap-1.5">
<span className="text-xs text-gray-400 w-14 shrink-0">{gp.point} ({gp.design})</span>
<Input
type="number"
placeholder={gp.design}
value={pState.gapMeasured[gi] || ''}
onChange={(e) => {
const newGaps = [...pState.gapMeasured];
newGaps[gi] = e.target.value;
updateProduct({ gapMeasured: newGaps });
}}
className="h-9 rounded-lg border-gray-300 text-sm"
/>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</>
);
})}
</div>
)}
{/* 부적합 내용 */}

View File

@@ -5,10 +5,16 @@
*
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
*
* 기능:
* - 기투입 LOT 표시 및 수정 (replace 모드)
* - 선택완료 배지
* - 필요수량 배정 완료 시에만 투입 가능
* - FIFO 자동입력 버튼
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Loader2, Check } from 'lucide-react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Loader2, Check, Zap } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import {
Dialog,
@@ -78,8 +84,10 @@ export function MaterialInputModal({
}: MaterialInputModalProps) {
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
const [manualAllocations, setManualAllocations] = useState<Map<string, number>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const materialsLoadedRef = useRef(false);
// 목업 자재 데이터 (개발용)
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({
@@ -95,20 +103,34 @@ export function MaterialInputModal({
fifoRank: i + 1,
}));
// 로트 키 생성
const getLotKey = (material: MaterialForInput) =>
String(material.stockLotId ?? `item-${material.itemId}`);
// 로트 키 생성 (그룹별 독립 — 같은 LOT가 여러 그룹에 있어도 구분)
const getLotKey = useCallback((material: MaterialForInput, groupKey: string) =>
`${String(material.stockLotId ?? `item-${material.itemId}`)}__${groupKey}`, []);
// 품목별 그룹핑
// 기투입 LOT 존재 여부
const hasPreInputted = useMemo(() => {
return materials.some(m => {
const itemInput = m as unknown as MaterialForItemInput;
return (itemInput.lotInputtedQty ?? 0) > 0;
});
}, [materials]);
// 품목별 그룹핑 (BOM 엔트리별 고유키 사용 — 같은 item_id라도 category+partType 다르면 별도 그룹)
const materialGroups: MaterialGroup[] = useMemo(() => {
// dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑
const groups = new Map<string, MaterialForInput[]>();
for (const m of materials) {
const groupKey = m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId);
const itemInput = m as unknown as MaterialForItemInput;
const groupKey = itemInput.bomGroupKey
?? (m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId));
const existing = groups.get(groupKey) || [];
existing.push(m);
groups.set(groupKey, existing);
}
// 작업일지와 동일한 카테고리 순서
const categoryOrder: Record<string, number> = {
guideRail: 0, bottomBar: 1, shutterBox: 2, smokeBarrier: 3,
};
return Array.from(groups.entries()).map(([groupKey, lots]) => {
const first = lots[0];
const itemInput = first as unknown as MaterialForItemInput;
@@ -129,37 +151,71 @@ export function MaterialInputModal({
partType: first.partType,
category: first.category,
};
}).sort((a, b) => {
const catA = categoryOrder[a.category ?? ''] ?? 99;
const catB = categoryOrder[b.category ?? ''] ?? 99;
return catA - catB;
});
}, [materials]);
// 선택된 로트에 FIFO 순서로 자동 배분 계산
// 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량)
const getGroupTargetQty = useCallback((group: MaterialGroup) => {
return group.alreadyInputted > 0 ? group.requiredQty : group.effectiveRequiredQty;
}, []);
// 배정 수량 계산 (manual 우선 → 나머지 FIFO 자동배분, 물리LOT 교차그룹 추적)
const allocations = useMemo(() => {
const result = new Map<string, number>();
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
for (const group of materialGroups) {
let remaining = group.effectiveRequiredQty;
const targetQty = getGroupTargetQty(group);
let remaining = targetQty;
// 1차: manual allocations 적용
for (const lot of group.lots) {
const lotKey = getLotKey(lot);
if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) {
const alloc = Math.min(lot.lotAvailableQty, remaining);
const lotKey = getLotKey(lot, group.groupKey);
if (selectedLotKeys.has(lotKey) && lot.stockLotId && manualAllocations.has(lotKey)) {
const val = manualAllocations.get(lotKey)!;
result.set(lotKey, val);
remaining -= val;
physicalUsed.set(lot.stockLotId, (physicalUsed.get(lot.stockLotId) || 0) + val);
}
}
// 2차: non-manual 선택 로트 FIFO 자동배분 (물리LOT 가용량 고려)
for (const lot of group.lots) {
const lotKey = getLotKey(lot, group.groupKey);
if (selectedLotKeys.has(lotKey) && lot.stockLotId && !manualAllocations.has(lotKey)) {
const itemInput = lot as unknown as MaterialForItemInput;
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
const used = physicalUsed.get(lot.stockLotId) || 0;
const effectiveAvail = Math.max(0, maxAvail - used);
const alloc = remaining > 0 ? Math.min(effectiveAvail, remaining) : 0;
result.set(lotKey, alloc);
remaining -= alloc;
if (alloc > 0) {
physicalUsed.set(lot.stockLotId, used + alloc);
}
}
}
}
return result;
}, [materialGroups, selectedLotKeys]);
}, [materialGroups, selectedLotKeys, manualAllocations, getLotKey, getGroupTargetQty]);
// 전체 배정 완료 여부
const allGroupsFulfilled = useMemo(() => {
if (materialGroups.length === 0) return false;
return materialGroups.every((group) => {
const targetQty = getGroupTargetQty(group);
if (targetQty <= 0) return true;
const allocated = group.lots.reduce(
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
0
);
return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty;
return allocated >= targetQty;
});
}, [materialGroups, allocations]);
}, [materialGroups, allocations, getLotKey, getGroupTargetQty]);
// 배정된 항목 존재 여부
const hasAnyAllocation = useMemo(() => {
@@ -172,6 +228,11 @@ export function MaterialInputModal({
const next = new Set(prev);
if (next.has(lotKey)) {
next.delete(lotKey);
setManualAllocations(prev => {
const n = new Map(prev);
n.delete(lotKey);
return n;
});
} else {
next.add(lotKey);
}
@@ -179,16 +240,60 @@ export function MaterialInputModal({
});
}, []);
// 수량 수동 변경
const handleAllocationChange = useCallback((lotKey: string, value: number, maxAvailable: number) => {
const clamped = Math.max(0, Math.min(value, maxAvailable));
setManualAllocations(prev => {
const next = new Map(prev);
next.set(lotKey, clamped);
return next;
});
}, []);
// FIFO 자동입력 (물리LOT 교차그룹 가용량 추적)
const handleAutoFill = useCallback(() => {
const newSelected = new Set<string>();
const newAllocations = new Map<string, number>();
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
for (const group of materialGroups) {
const targetQty = getGroupTargetQty(group);
if (targetQty <= 0) continue;
let remaining = targetQty;
for (const lot of group.lots) {
if (!lot.stockLotId || remaining <= 0) continue;
const lotKey = getLotKey(lot, group.groupKey);
const itemInput = lot as unknown as MaterialForItemInput;
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
const used = physicalUsed.get(lot.stockLotId) || 0;
const effectiveAvail = Math.max(0, maxAvail - used);
const alloc = Math.min(effectiveAvail, remaining);
if (alloc > 0) {
newSelected.add(lotKey);
newAllocations.set(lotKey, alloc);
remaining -= alloc;
physicalUsed.set(lot.stockLotId, used + alloc);
}
}
}
setSelectedLotKeys(newSelected);
setManualAllocations(newAllocations);
}, [materialGroups, getLotKey, getGroupTargetQty]);
// API로 자재 목록 로드
const loadMaterials = useCallback(async () => {
if (!order) return;
setIsLoading(true);
materialsLoadedRef.current = false;
try {
// 목업 아이템인 경우 목업 자재 데이터 사용
if (order.id.startsWith('mock-')) {
setMaterials(MOCK_MATERIALS);
setIsLoading(false);
materialsLoadedRef.current = true;
return;
}
@@ -204,6 +309,7 @@ export function MaterialInputModal({
workOrderItemId: m.workOrderItemId || itemId,
}));
setMaterials(tagged);
materialsLoadedRef.current = true;
} else {
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
}
@@ -212,6 +318,7 @@ export function MaterialInputModal({
const result = await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
materialsLoadedRef.current = true;
} else {
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
}
@@ -230,27 +337,53 @@ export function MaterialInputModal({
if (open && order) {
loadMaterials();
setSelectedLotKeys(new Set());
setManualAllocations(new Map());
}
}, [open, order, loadMaterials]);
// 자재 로드 후 기투입 LOT 자동 선택 (그룹별 독립 처리)
useEffect(() => {
if (!materialsLoadedRef.current || materials.length === 0 || materialGroups.length === 0) return;
const preSelected = new Set<string>();
const preAllocations = new Map<string, number>();
for (const group of materialGroups) {
for (const m of group.lots) {
const itemInput = m as unknown as MaterialForItemInput;
const lotInputted = itemInput.lotInputtedQty ?? 0;
if (lotInputted > 0 && m.stockLotId) {
const lotKey = getLotKey(m, group.groupKey);
preSelected.add(lotKey);
preAllocations.set(lotKey, lotInputted);
}
}
}
if (preSelected.size > 0) {
setSelectedLotKeys(prev => new Set([...prev, ...preSelected]));
setManualAllocations(prev => new Map([...prev, ...preAllocations]));
}
// 한 번만 실행하도록 ref 초기화
materialsLoadedRef.current = false;
}, [materials, materialGroups, getLotKey]);
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
// 배분된 로트만 추출 (dynamic_bom이면 work_order_item_id 포함)
const inputs: { stock_lot_id: number; qty: number; work_order_item_id?: number }[] = [];
for (const [lotKey, allocQty] of allocations) {
if (allocQty > 0) {
const material = materials.find((m) => getLotKey(m) === lotKey);
if (material?.stockLotId) {
const input: { stock_lot_id: number; qty: number; work_order_item_id?: number } = {
stock_lot_id: material.stockLotId,
// 배분된 로트를 그룹별 개별 엔트리로 추출 (bom_group_key 포함)
const inputs: { stock_lot_id: number; qty: number; bom_group_key: string }[] = [];
for (const group of materialGroups) {
for (const lot of group.lots) {
const lotKey = getLotKey(lot, group.groupKey);
const allocQty = allocations.get(lotKey) || 0;
if (allocQty > 0 && lot.stockLotId) {
inputs.push({
stock_lot_id: lot.stockLotId,
qty: allocQty,
};
if (material.workOrderItemId) {
input.work_order_item_id = material.workOrderItemId;
}
inputs.push(input);
bom_group_key: group.groupKey,
});
}
}
}
@@ -268,8 +401,8 @@ export function MaterialInputModal({
?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null);
if (targetItemId) {
const simpleInputs = inputs.map(({ stock_lot_id, qty }) => ({ stock_lot_id, qty }));
result = await registerMaterialInputForItem(order.id, targetItemId, simpleInputs);
// 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록)
result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted);
} else {
result = await registerMaterialInput(order.id, inputs);
}
@@ -278,18 +411,26 @@ export function MaterialInputModal({
toast.success('자재 투입이 등록되었습니다.');
if (onSaveMaterials) {
// 표시용: 같은 LOT는 합산 (자재투입목록 UI)
const lotTotals = new Map<number, number>();
for (const inp of inputs) {
lotTotals.set(inp.stock_lot_id, (lotTotals.get(inp.stock_lot_id) || 0) + inp.qty);
}
const savedList: MaterialInput[] = [];
for (const [lotKey, allocQty] of allocations) {
if (allocQty > 0) {
const material = materials.find((m) => getLotKey(m) === lotKey);
if (material) {
const processedLotIds = new Set<number>();
for (const group of materialGroups) {
for (const lot of group.lots) {
if (!lot.stockLotId || processedLotIds.has(lot.stockLotId)) continue;
const totalQty = lotTotals.get(lot.stockLotId) || 0;
if (totalQty > 0) {
processedLotIds.add(lot.stockLotId);
savedList.push({
id: String(material.stockLotId),
lotNo: material.lotNo || '',
materialName: material.materialName,
quantity: material.lotAvailableQty,
unit: material.unit,
inputQuantity: allocQty,
id: String(lot.stockLotId),
lotNo: lot.lotNo || '',
materialName: lot.materialName,
quantity: lot.lotAvailableQty,
unit: lot.unit,
inputQuantity: totalQty,
});
}
}
@@ -316,6 +457,7 @@ export function MaterialInputModal({
const resetAndClose = () => {
setSelectedLotKeys(new Set());
setManualAllocations(new Map());
onOpenChange(false);
};
@@ -329,9 +471,20 @@ export function MaterialInputModal({
<DialogTitle className="text-xl font-semibold">
{workOrderItemName ? ` - ${workOrderItemName}` : ''}
</DialogTitle>
<p className="text-sm text-gray-500 mt-1">
.
</p>
<div className="flex items-center justify-between mt-1">
<p className="text-sm text-gray-500">
.
</p>
{!isLoading && materials.length > 0 && (
<button
onClick={handleAutoFill}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors shrink-0"
>
<Zap className="h-3 w-3" />
</button>
)}
</div>
</DialogHeader>
<div className="px-6 pb-6 space-y-4 flex-1 min-h-0 flex flex-col">
@@ -344,13 +497,22 @@ export function MaterialInputModal({
</div>
) : (
<div className="space-y-4 flex-1 overflow-y-auto min-h-0">
{materialGroups.map((group) => {
{materialGroups.map((group, groupIdx) => {
// 같은 카테고리 내 순번 계산 (①②③...)
const categoryIndex = group.category
? materialGroups.slice(0, groupIdx).filter(g => g.category === group.category).length
: -1;
const circledNumbers = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'];
const circledNum = categoryIndex >= 0 && categoryIndex < circledNumbers.length
? circledNumbers[categoryIndex] : '';
const targetQty = getGroupTargetQty(group);
const groupAllocated = group.lots.reduce(
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
0
);
const isAlreadyComplete = group.effectiveRequiredQty <= 0;
const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty;
const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0;
const isFulfilled = isGroupComplete || groupAllocated >= targetQty;
return (
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
@@ -372,6 +534,11 @@ export function MaterialInputModal({
group.category}
</span>
)}
{group.partType && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 font-medium">
{circledNum}{group.partType}
</span>
)}
<span className="text-sm font-semibold text-gray-900">
{group.materialName}
</span>
@@ -387,10 +554,10 @@ export function MaterialInputModal({
<>
:{' '}
<span className="font-semibold text-gray-900">
{fmtQty(group.effectiveRequiredQty)}
{fmtQty(group.requiredQty)}
</span>{' '}
{group.unit}
<span className="ml-1 text-gray-400">
<span className="ml-1 text-blue-500">
(: {fmtQty(group.alreadyInputted)})
</span>
</>
@@ -406,27 +573,20 @@ export function MaterialInputModal({
</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
isAlreadyComplete
isFulfilled
? 'bg-emerald-100 text-emerald-700'
: isFulfilled
? 'bg-emerald-100 text-emerald-700'
: groupAllocated > 0
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-500'
: groupAllocated > 0
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{isAlreadyComplete ? (
<>
<Check className="h-3 w-3" />
</>
) : isFulfilled ? (
{isFulfilled ? (
<>
<Check className="h-3 w-3" />
</>
) : (
`${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}`
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
)}
</span>
</div>
@@ -437,7 +597,7 @@ export function MaterialInputModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-20"></TableHead>
<TableHead className="text-center w-24"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
@@ -446,20 +606,24 @@ export function MaterialInputModal({
</TableHeader>
<TableBody>
{group.lots.map((lot, idx) => {
const lotKey = getLotKey(lot);
const lotKey = getLotKey(lot, group.groupKey);
const hasStock = lot.stockLotId !== null;
const isSelected = selectedLotKeys.has(lotKey);
const allocated = allocations.get(lotKey) || 0;
const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected);
const itemInput = lot as unknown as MaterialForItemInput;
const lotInputted = itemInput.lotInputtedQty ?? 0;
const isPreInputted = lotInputted > 0;
// 가용수량 = 현재 가용 + 기투입분 (replace 시 복원되므로)
const effectiveAvailable = lot.lotAvailableQty + lotInputted;
const canSelect = hasStock && (!isFulfilled || isSelected);
return (
<TableRow
key={`${lotKey}-${idx}`}
className={
isSelected && allocated > 0
? 'bg-blue-50/50'
: ''
}
className={cn(
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
)}
>
<TableCell className="text-center">
{hasStock ? (
@@ -467,7 +631,7 @@ export function MaterialInputModal({
onClick={() => toggleLot(lotKey)}
disabled={!canSelect}
className={cn(
'min-w-[56px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
'min-w-[64px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
isSelected
? 'bg-blue-600 text-white shadow-sm'
: canSelect
@@ -475,20 +639,34 @@ export function MaterialInputModal({
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
{isSelected ? '선택' : '선택'}
{isSelected ? '선택완료' : '선택'}
</button>
) : null}
</TableCell>
<TableCell className="text-center text-sm">
{lot.lotNo || (
<span className="text-gray-400">
</span>
)}
<div className="flex items-center justify-center gap-1">
{lot.lotNo || (
<span className="text-gray-400">
</span>
)}
{isPreInputted && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">
</span>
)}
</div>
</TableCell>
<TableCell className="text-center text-sm">
{hasStock ? (
fmtQty(lot.lotAvailableQty)
isPreInputted ? (
<span>
{fmtQty(lot.lotAvailableQty)}
<span className="text-blue-500 text-xs ml-1">(+{fmtQty(lotInputted)})</span>
</span>
) : (
fmtQty(lot.lotAvailableQty)
)
) : (
<span className="text-red-500">0</span>
)}
@@ -497,7 +675,19 @@ export function MaterialInputModal({
{lot.unit}
</TableCell>
<TableCell className="text-center text-sm font-medium">
{allocated > 0 ? (
{isSelected && hasStock ? (
<input
type="number"
value={allocated || ''}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
handleAllocationChange(lotKey, val, effectiveAvailable);
}}
className="w-20 text-center text-blue-600 font-semibold border border-blue-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
min={0}
max={effectiveAvailable}
/>
) : allocated > 0 ? (
<span className="text-blue-600">
{fmtQty(allocated)}
</span>
@@ -529,7 +719,7 @@ export function MaterialInputModal({
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !hasAnyAllocation}
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
@@ -546,4 +736,4 @@ export function MaterialInputModal({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -58,7 +58,7 @@ export function WorkCard({
{/* 헤더 박스: 품목명 + 수량 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">
{order.productName}
{order.productCode !== '-' ? order.productCode : order.productName}
</h3>
<div className="text-right">
<span className="text-2xl font-bold text-gray-900">{order.quantity}</span>

View File

@@ -14,7 +14,7 @@
*/
import { useState, useCallback, memo } from 'react';
import { ChevronDown, ChevronUp, SquarePen, Trash2, ImageIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, SquarePen, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -291,45 +291,26 @@ import type { BendingInfo, WipInfo } from './types';
function BendingExtraInfo({ info }: { info: BendingInfo }) {
return (
<div className="space-y-3">
{/* 도면 + 공통사항 (가로 배치) */}
<div className="flex gap-3">
{/* 도면 이미지 */}
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{info.drawingUrl ? (
<img
src={info.drawingUrl}
alt="도면"
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-6 w-6" />
<span className="text-[10px]"></span>
</div>
)}
</div>
{/* 공통사항 */}
<div className="flex-1 space-y-1.5">
<p className="text-xs font-medium text-gray-500"></p>
<div className="space-y-1">
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.kind}</span>
</div>
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.type}</span>
</div>
{info.common.lengthQuantities.map((lq, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
<span className="text-gray-900 font-medium">
{formatNumber(lq.length)}mm X {lq.quantity}
</span>
</div>
))}
{/* 공통사항 */}
<div>
<p className="text-xs font-medium text-gray-500"></p>
<div className="space-y-1 mt-1.5">
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.kind}</span>
</div>
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.type}</span>
</div>
{info.common.lengthQuantities.map((lq, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
<span className="text-gray-900 font-medium">
{formatNumber(lq.length)}mm X {lq.quantity}
</span>
</div>
))}
</div>
</div>
@@ -361,35 +342,16 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
function WipExtraInfo({ info }: { info: WipInfo }) {
return (
<div className="flex gap-4">
{/* 도면 이미지 (큰 영역) */}
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{info.drawingUrl ? (
<img
src={info.drawingUrl}
alt="도면"
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-8 w-8" />
<span className="text-xs">IMG</span>
</div>
)}
</div>
{/* 공통사항 */}
<div className="flex-1 space-y-0">
<p className="text-xs font-medium text-gray-500 mb-2"></p>
<div className="border rounded-lg divide-y">
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"></span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
</div>
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"> </span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-2"></p>
<div className="border rounded-lg divide-y">
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"></span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
</div>
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"> </span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
</div>
</div>
</div>

View File

@@ -11,13 +11,13 @@
* - 양식 미매핑 시 processType 폴백
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getWorkOrderById, getMaterialInputLots } from '../WorkOrders/actions';
import { saveWorkLog } from './actions';
import { getWorkOrderById, getMaterialInputLots, patchDocumentSnapshot } from '../WorkOrders/actions';
import { saveWorkLog, getWorkLog } from './actions';
import type { MaterialInputLot } from '../WorkOrders/actions';
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
import { WorkLogContent } from './WorkLogContent';
@@ -63,6 +63,9 @@ export function WorkLogModal({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Lazy Snapshot 대상 문서 ID
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
@@ -115,8 +118,9 @@ export function WorkLogModal({
Promise.all([
getWorkOrderById(workOrderId),
getMaterialInputLots(workOrderId),
getWorkLog(workOrderId),
])
.then(([orderResult, lotsResult]) => {
.then(([orderResult, lotsResult, workLogResult]) => {
if (orderResult.success && orderResult.data) {
setOrder(orderResult.data);
} else {
@@ -125,6 +129,13 @@ export function WorkLogModal({
if (lotsResult.success) {
setMaterialLots(lotsResult.data);
}
// Lazy Snapshot: 문서가 있고 rendered_html이 없으면 스냅샷 대상
if (workLogResult.success && workLogResult.data?.document) {
const doc = workLogResult.data.document as { id?: number; rendered_html?: string | null };
if (doc.id && !doc.rendered_html) {
setSnapshotDocumentId(doc.id);
}
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
@@ -136,10 +147,29 @@ export function WorkLogModal({
// 모달 닫힐 때 상태 초기화
setOrder(null);
setMaterialLots([]);
setSnapshotDocumentId(null);
setError(null);
}
}, [open, workOrderId, processType]);
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
useEffect(() => {
if (!snapshotDocumentId || isLoading || !order) return;
const timer = setTimeout(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
if (result.success) {
setSnapshotDocumentId(null);
}
});
}
}, 500);
return () => clearTimeout(timer);
}, [snapshotDocumentId, isLoading, order]);
// 저장 핸들러
const handleSave = useCallback(async () => {
if (!workOrderId || !order) return;
@@ -155,9 +185,13 @@ export function WorkLogModal({
unit: item.unit || 'EA',
}));
// HTML 스냅샷 캡처 (MNG 출력용)
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
const result = await saveWorkLog(workOrderId, {
table_data: tableData,
title: workLogTemplateName || '작업일지',
rendered_html: renderedHtml,
});
if (result.success) {
@@ -255,7 +289,9 @@ export function WorkLogModal({
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
renderContent()
<div ref={contentWrapperRef}>
{renderContent()}
</div>
)}
</DocumentViewer>
);

View File

@@ -74,8 +74,10 @@ export function WorkOrderListPanel({
</div>
</div>
{/* 제품코드 - 제품명 */}
<p className="text-sm text-gray-600 truncate ml-8">{order.productCode} - {order.productName}</p>
{/* 제품코드 (제품명) */}
<p className="text-sm text-gray-600 truncate ml-8">
{order.productCode !== '-' ? order.productCode : order.productName}
</p>
{/* 현장명 + 수량 */}
<div className="flex items-center justify-between mt-1.5 ml-8">

View File

@@ -316,6 +316,8 @@ export async function registerMaterialInput(
export interface MaterialForItemInput extends MaterialForInput {
alreadyInputted: number; // 이미 투입된 수량
remainingRequiredQty: number; // 남은 필요 수량
lotInputtedQty: number; // 해당 LOT의 기투입 수량
bomGroupKey?: string; // BOM 엔트리별 고유 그룹키 (category+partType 기반)
}
export async function getMaterialsForItem(
@@ -330,12 +332,13 @@ export async function getMaterialsForItem(
stock_lot_id: number | null; item_id: number; lot_no: string | null;
material_code: string; material_name: string; specification: string;
unit: string; bom_qty: number; required_qty: number;
already_inputted: number; remaining_required_qty: number;
already_inputted: number; remaining_required_qty: number; lot_inputted_qty: number;
lot_available_qty: number; fifo_rank: number;
lot_qty: number; lot_reserved_qty: number;
receipt_date: string | null; supplier: string | null;
// dynamic_bom 추가 필드
work_order_item_id?: number; lot_prefix?: string; part_type?: string; category?: string;
bom_group_key?: string;
}
const result = await executeServerAction<MaterialItemApiItem[]>({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/materials`,
@@ -352,8 +355,10 @@ export async function getMaterialsForItem(
fifoRank: item.fifo_rank,
alreadyInputted: item.already_inputted,
remainingRequiredQty: item.remaining_required_qty,
lotInputtedQty: item.lot_inputted_qty ?? 0,
workOrderItemId: item.work_order_item_id, lotPrefix: item.lot_prefix,
partType: item.part_type, category: item.category,
bomGroupKey: item.bom_group_key,
})),
};
}
@@ -362,12 +367,13 @@ export async function getMaterialsForItem(
export async function registerMaterialInputForItem(
workOrderId: string,
itemId: number,
inputs: { stock_lot_id: number; qty: number }[]
inputs: { stock_lot_id: number; qty: number; bom_group_key?: string }[],
replace = false
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
method: 'POST',
body: { inputs },
body: { inputs, replace },
errorMessage: '개소별 자재 투입 등록에 실패했습니다.',
});
return { success: result.success, error: result.error };
@@ -672,9 +678,9 @@ export async function getWorkOrderDetail(
}
if (opts.is_wip) {
workItem.isWip = true;
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
if (wi) {
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
}
}
if (opts.is_joint_bar) {
@@ -738,6 +744,7 @@ export async function saveWorkLog(
table_data?: Array<Record<string, unknown>>;
remarks?: string;
title?: string;
rendered_html?: string;
}
): Promise<{
success: boolean;

View File

@@ -45,7 +45,7 @@ import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderIns
import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions';
import type { InspectionTemplateData } from './types';
import { getProcessList } from '@/components/process-management/actions';
import type { InspectionSetting, Process } from '@/types/process';
import type { InspectionSetting, InspectionScope, Process } from '@/types/process';
import type { WorkOrder } from '../ProductionDashboard/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type {
@@ -348,15 +348,28 @@ export default function WorkerScreen() {
// 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] }
const [stepProgressMap, setStepProgressMap] = useState<Record<string, StepProgressItem[]>>({});
// 데이터 로드
// 데이터 로드 (작업목록 + 공정목록 + 부서목록 병렬)
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getMyWorkOrders();
if (result.success) {
setWorkOrders(result.data);
const [workOrderResult, processResult, deptResult] = await Promise.all([
getMyWorkOrders(),
getProcessList({ size: 100 }),
getDepartments(),
]);
if (workOrderResult.success) {
setWorkOrders(workOrderResult.data);
} else {
toast.error(result.error || '작업 목록 조회에 실패했습니다.');
toast.error(workOrderResult.error || '작업 목록 조회에 실패했습니다.');
}
if (processResult.success && processResult.data?.items) {
setProcessListCache(processResult.data.items);
}
if (deptResult.success) {
setDepartmentList(deptResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -369,10 +382,6 @@ export default function WorkerScreen() {
useEffect(() => {
loadData();
// 부서 목록 로드
getDepartments().then((res) => {
if (res.success) setDepartmentList(res.data);
});
}, [loadData]);
// 부서 선택 시 해당 부서 사용자 목록 로드
@@ -416,6 +425,8 @@ export default function WorkerScreen() {
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
// 공정의 중간검사 설정
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
// 공정의 검사 범위 설정
const [currentInspectionScope, setCurrentInspectionScope] = useState<InspectionScope | undefined>();
// 문서 템플릿 데이터 (document_template 기반 동적 검사용)
const [inspectionTemplateData, setInspectionTemplateData] = useState<InspectionTemplateData | undefined>();
const [inspectionDimensions, setInspectionDimensions] = useState<{ width?: number; height?: number }>({});
@@ -453,21 +464,6 @@ export default function WorkerScreen() {
// 공정 목록 캐시
const [processListCache, setProcessListCache] = useState<Process[]>([]);
// 공정 목록 조회 (최초 1회)
useEffect(() => {
const fetchProcessList = async () => {
try {
const result = await getProcessList({ size: 100 });
if (result.success && result.data?.items) {
setProcessListCache(result.data.items);
}
} catch (error) {
console.error('Failed to fetch process list:', error);
}
};
fetchProcessList();
}, []);
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
const processTabs = useMemo(() => {
return processListCache.filter((p) => p.status === '사용중');
@@ -513,8 +509,10 @@ export default function WorkerScreen() {
(step) => step.needsInspection && step.inspectionSetting
);
setCurrentInspectionSetting(inspectionStep?.inspectionSetting);
setCurrentInspectionScope(inspectionStep?.inspectionScope);
} else {
setCurrentInspectionSetting(undefined);
setCurrentInspectionScope(undefined);
}
}, [activeTab, processListCache]);
@@ -714,7 +712,7 @@ export default function WorkerScreen() {
workOrderId: selectedOrder.id,
itemNo: index + 1,
itemCode: selectedOrder.orderNo || '-',
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${itemSummary}`,
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : itemSummary,
floor: (opts.floor as string) || '-',
code: (opts.code as string) || '-',
width: (opts.width as number) || 0,
@@ -740,15 +738,15 @@ export default function WorkerScreen() {
detail_parts: { part_name: string; material: string; barcy_info: string }[];
};
workItem.bendingInfo = {
common: { kind: bi.common.kind, type: bi.common.type, lengthQuantities: bi.common.length_quantities || [] },
common: { kind: bi.common?.kind || '', type: bi.common?.type || '', lengthQuantities: bi.common?.length_quantities || [] },
detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })),
};
}
if (opts.is_wip) {
workItem.isWip = true;
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
if (wi) {
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
}
}
if (opts.is_joint_bar) {
@@ -774,7 +772,7 @@ export default function WorkerScreen() {
workOrderId: selectedOrder.id,
itemNo: 1,
itemCode: selectedOrder.orderNo || '-',
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${selectedOrder.productName || '-'}`,
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder.productName || '-'),
floor: '-',
code: '-',
width: 0,
@@ -809,6 +807,50 @@ export default function WorkerScreen() {
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
// 수주 단위로 적용: API 아이템(실제 수주 개소)에만 scope 적용
// 목업 아이템은 각각 독립 1개소이므로 항상 검사 버튼 유지
const scopedWorkItems: WorkItemData[] = useMemo(() => {
if (!currentInspectionScope || currentInspectionScope.type === 'all') {
return workItems;
}
// 실제 수주 아이템만 분리 (목업 제외)
const apiItems = workItems.filter((item) => !item.id.startsWith('mock-'));
const apiCount = apiItems.length;
if (apiCount === 0) return workItems;
// 검사 단계를 아예 제거하는 헬퍼
const removeInspectionSteps = (item: WorkItemData): WorkItemData => ({
...item,
steps: item.steps.filter((step) => !step.isInspection && !step.needsInspection),
});
let realIdx = 0;
if (currentInspectionScope.type === 'sampling') {
const sampleSize = currentInspectionScope.sampleSize || 1;
return workItems.map((item) => {
// 목업은 독립 1개소 → 검사 유지
if (item.id.startsWith('mock-')) return item;
const isInSampleRange = realIdx >= apiCount - sampleSize;
realIdx++;
return isInSampleRange ? item : removeInspectionSteps(item);
});
}
if (currentInspectionScope.type === 'group') {
return workItems.map((item) => {
if (item.id.startsWith('mock-')) return item;
const isLast = realIdx === apiCount - 1;
realIdx++;
return isLast ? item : removeInspectionSteps(item);
});
}
return workItems;
}, [workItems, currentInspectionScope]);
// ===== 작업지시 선택 시 기존 검사 데이터 로드 =====
// workItems 선언 이후에 위치해야 workItems.length 참조 가능
// workItems.length 의존성: selectedSidebarOrderId 변경 시점에 workItems가 아직 비어있을 수 있음
@@ -827,20 +869,39 @@ export default function WorkerScreen() {
const completionUpdates: Record<string, boolean> = {};
setInspectionDataMap((prev) => {
const next = new Map(prev);
for (const apiItem of result.data!.items) {
if (!apiItem.inspection_data) continue;
// workItems에서 apiItemId가 일치하는 항목 찾기
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
if (match) {
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
// 검사 step 완료 처리 (실제 step name 사용)
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
if (inspStep) {
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
completionUpdates[stepKey] = true;
// 절곡 공정: 수주 단위 검사 → 어떤 item이든 inspection_data 있으면 모든 개소가 공유
const isBendingProcess = workItems.some(w => w.processType === 'bending');
if (isBendingProcess) {
const bendingItem = result.data!.items.find(i => i.inspection_data);
if (bendingItem?.inspection_data) {
for (const w of workItems) {
next.set(w.id, bendingItem.inspection_data as unknown as InspectionData);
const inspStep = w.steps.find((s) => s.isInspection || s.needsInspection);
if (inspStep) {
const stepKey = `${w.id.replace('-node-', '-')}-${inspStep.name}`;
completionUpdates[stepKey] = true;
}
}
}
} else {
// 기존: item별 개별 매칭
for (const apiItem of result.data!.items) {
if (!apiItem.inspection_data) continue;
// workItems에서 apiItemId가 일치하는 항목 찾기
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
if (match) {
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
// 검사 step 완료 처리 (실제 step name 사용)
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
if (inspStep) {
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
completionUpdates[stepKey] = true;
}
}
}
}
return next;
});
// stepCompletionMap 일괄 업데이트
@@ -1300,9 +1361,18 @@ export default function WorkerScreen() {
`${selectedOrder.id.replace('-node-', '-')}-${stepName}`;
// 메모리에 즉시 반영
// 절곡: 수주 단위 검사 → 모든 개소가 동일한 검사 데이터 공유
const inspProcessType = getInspectionProcessType();
const isBendingInsp = inspProcessType === 'bending' || inspProcessType === 'bending_wip';
setInspectionDataMap((prev) => {
const next = new Map(prev);
next.set(selectedOrder.id, data);
if (isBendingInsp) {
for (const w of workItems) {
next.set(w.id, data);
}
} else {
next.set(selectedOrder.id, data);
}
return next;
});
@@ -1402,6 +1472,9 @@ export default function WorkerScreen() {
</div>
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
{isLoading ? (
<ContentSkeleton type="list" rows={1} />
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v)}
@@ -1601,14 +1674,14 @@ export default function WorkerScreen() {
</span>
) : null;
})()}
{workItems.map((item, index) => {
{scopedWorkItems.map((item, index) => {
const isFirstMock = item.id.startsWith('mock-') &&
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
return (
<div key={item.id}>
{isFirstMock && (
<div className="mb-3 pt-1 space-y-2">
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
</span>
@@ -1632,11 +1705,12 @@ export default function WorkerScreen() {
</TabsContent>
))}
</Tabs>
)}
</div>
{/* 하단 고정 버튼 */}
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
<div className={`fixed bottom-4 left-3 right-3 px-3 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:left-auto md:right-[24px] md:px-6 ${sidebarCollapsed ? 'md:left-[113px]' : 'md:left-[304px]'}`}>
<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-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex gap-2 md:gap-3">
{hasWipItems ? (
<Button
@@ -1763,12 +1837,13 @@ export default function WorkerScreen() {
open={isInspectionInputModalOpen}
onOpenChange={setIsInspectionInputModalOpen}
processType={getInspectionProcessType()}
productName={selectedOrder?.productName || workItems[0]?.itemName || ''}
productName={selectedOrder?.productCode && selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder?.productName || workItems[0]?.itemName || '')}
specification={workItems[0]?.slatJointBarInfo?.specification || workItems[0]?.wipInfo?.specification || ''}
initialData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
onComplete={handleInspectionComplete}
templateData={inspectionTemplateData}
workItemDimensions={inspectionDimensions}
workOrderId={workItems.find(w => w.id === selectedOrder?.id)?.workOrderId}
/>
</PageLayout>
);

View File

@@ -62,7 +62,6 @@ export interface WorkItemData {
// ===== 재공품 전용 정보 =====
export interface WipInfo {
drawingUrl?: string; // 도면 이미지 URL
specification: string; // 규격 (EGI 1.55T (W576))
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
}
@@ -90,7 +89,6 @@ export interface SlatJointBarInfo {
// ===== 절곡 전용 정보 =====
export interface BendingInfo {
drawingUrl?: string; // 도면 이미지 URL
common: BendingCommonInfo; // 공통사항
detailParts: BendingDetailPart[]; // 세부부품
}