feat: [생산] 작업지시/작업자화면/대시보드 개선

- 검사문서 모달 및 템플릿 기능 확장
- WorkOrders actions 추가
- 작업자화면 WorkOrderListPanel 개선
- 생산대시보드 actions/타입 보강

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-01 12:20:41 +09:00
parent 181352d7a9
commit 2a2a356f58
8 changed files with 217 additions and 20 deletions

View File

@@ -23,10 +23,11 @@ interface WorkOrderApiItem {
created_at: string;
sales_order?: {
id: number; order_no: string; client_id?: number; client_name?: string;
item?: { id: number; code: string; name: string } | null;
client?: { id: number; name: string }; root_nodes_count?: number;
};
assignee?: { id: number; name: string };
items?: { id: number; item_name: string; quantity: number }[];
items?: { id: number; item_name: string; item_id?: number | null; item?: { id: number; code: string; name: string } | null; quantity: number; options?: Record<string, unknown> | null }[];
}
// ===== 상태 변환 =====
@@ -41,7 +42,8 @@ function mapApiStatus(status: WorkOrderApiItem['status']): 'waiting' | 'inProgre
// ===== API → WorkOrder 변환 =====
function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
const productName = api.items?.[0]?.item_name || '-';
const productCode = (api.items?.[0]?.options?.product_code as string) || api.sales_order?.item?.code || '-';
const productName = (api.items?.[0]?.options?.product_name as string) || api.items?.[0]?.item_name || '-';
const dueDate = api.scheduled_date || '';
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -57,6 +59,7 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
return {
id: String(api.id),
orderNo: api.work_order_no,
productCode,
productName,
processCode: api.process?.process_code || '-',
processName: api.process?.process_name || '-',

View File

@@ -14,6 +14,7 @@ export interface ProcessOption {
export interface WorkOrder {
id: string;
orderNo: string; // KD-WO-251216-01
productCode: string; // 제품코드 (KQTS01 등)
productName: string; // 스크린 서터 (표준형) - 추가
processCode: string; // 공정 코드 (P-001, P-002, ...)
processName: string; // 공정명 (슬랫, 스크린, 절곡, ...)

View File

@@ -773,6 +773,53 @@ export async function saveInspectionData(
}
}
// ===== 검사 설정 조회 (공정 판별 + 구성품 목록) =====
export interface InspectionConfigGapPoint {
point: string;
design_value: string;
}
export interface InspectionConfigItem {
id: string;
name: string;
gap_points: InspectionConfigGapPoint[];
}
export interface InspectionConfigData {
work_order_id: number;
process_type: string;
product_code: string | null;
finishing_type: string | null;
template_id: number | null;
items: InspectionConfigItem[];
}
export async function getInspectionConfig(
workOrderId: string | number
): Promise<{ success: boolean; data?: InspectionConfigData; error?: string }> {
try {
const { response, error } = await serverFetch(
buildApiUrl(`/api/v1/work-orders/${workOrderId}/inspection-config`),
{ method: 'GET' }
);
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, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] getInspectionConfig error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 검사 문서 템플릿 조회 (document_template 기반) =====
import type { InspectionTemplateData } from '@/components/production/WorkerScreen/types';
@@ -874,6 +921,33 @@ export async function resolveInspectionDocument(
}
}
// ===== 문서 결재 상신 =====
export async function submitDocumentForApproval(
documentId: number
): Promise<{ success: boolean; error?: string }> {
try {
const { response, error } = await serverFetch(
buildApiUrl(`/api/v1/documents/${documentId}/submit`),
{ method: 'POST' }
);
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] submitDocumentForApproval error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 수주 목록 조회 (작업지시 생성용) =====
export interface SalesOrderForWorkOrder {
id: number;

View File

@@ -12,7 +12,7 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Loader2, Save } from 'lucide-react';
import { Loader2, Save, Send } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
@@ -23,6 +23,7 @@ import {
getInspectionTemplate,
saveInspectionDocument,
resolveInspectionDocument,
submitDocumentForApproval,
} from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import type { InspectionReportData, InspectionReportNodeGroup } from '../actions';
@@ -178,6 +179,10 @@ export function InspectionReportModal({
field_key: string;
field_value: string | null;
}> | null>(null);
// 기존 문서 ID/상태 (결재 상신용)
const [savedDocumentId, setSavedDocumentId] = useState<number | null>(null);
const [savedDocumentStatus, setSavedDocumentStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨)
// ★ 반드시 workItems와 inspectionDataMap을 같은 소스에서 가져와야 key 포맷이 일치함
@@ -289,18 +294,23 @@ export function InspectionReportModal({
setSelfTemplateData(templateResult.data);
}
// 4) 기존 문서의 document_data EAV 레코드 추출
// 4) 기존 문서의 document_data EAV 레코드 + ID/상태 추출
if (resolveResult?.success && resolveResult.data) {
const existingDoc = (resolveResult.data as Record<string, unknown>).existing_document as
| { data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> }
| { 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) {
setDocumentRecords(existingDoc.data);
} else {
setDocumentRecords(null);
}
// 문서 ID/상태 저장 (결재 상신용)
setSavedDocumentId(existingDoc?.id ?? null);
setSavedDocumentStatus(existingDoc?.status ?? null);
} else {
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
}
})
.catch(() => {
@@ -316,6 +326,8 @@ export function InspectionReportModal({
setReportSummary(null);
setSelfTemplateData(null);
setDocumentRecords(null);
setSavedDocumentId(null);
setSavedDocumentStatus(null);
setError(null);
}
}, [open, workOrderId, processType, templateData]);
@@ -350,6 +362,12 @@ export function InspectionReportModal({
});
if (result.success) {
toast.success('검사 문서가 저장되었습니다.');
// 저장 후 문서 ID/상태 갱신 (결재 상신 활성화용)
const docData = result.data as { id?: number; status?: string } | undefined;
if (docData?.id) {
setSavedDocumentId(docData.id);
setSavedDocumentStatus(docData.status ?? 'DRAFT');
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
@@ -369,6 +387,27 @@ export function InspectionReportModal({
}
}, [workOrderId, processType, activeTemplate, activeStepId]);
// 결재 상신 핸들러
const handleSubmitForApproval = useCallback(async () => {
if (!savedDocumentId) return;
setIsSubmitting(true);
try {
const result = await submitDocumentForApproval(savedDocumentId);
if (result.success) {
toast.success('결재 상신이 완료되었습니다.');
setSavedDocumentStatus('PENDING');
onOpenChange(false);
} else {
toast.error(result.error || '결재 상신에 실패했습니다.');
}
} catch {
toast.error('결재 상신 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
}, [savedDocumentId, onOpenChange]);
if (!workOrderId) return null;
const processLabel = PROCESS_LABELS[processType] || '스크린';
@@ -426,15 +465,35 @@ export function InspectionReportModal({
}
};
// 결재 상신 가능 여부: 저장된 DRAFT 문서가 있을 때
const canSubmitForApproval = savedDocumentId != null && savedDocumentStatus === 'DRAFT';
const toolbarExtra = !readOnly ? (
<Button onClick={handleSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
<div className="flex items-center gap-2">
<Button onClick={handleSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
{canSubmitForApproval && (
<Button
onClick={handleSubmitForApproval}
disabled={isSubmitting}
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{isSubmitting ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Send className="w-4 h-4 mr-1.5" />
)}
{isSubmitting ? '상신 중...' : '결재 상신'}
</Button>
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
) : undefined;
// 검사 진행 상태 표시 (summary 있을 때)

View File

@@ -36,6 +36,8 @@ import {
} from './inspection-shared';
import { formatNumber } from '@/lib/utils/amount';
import type { BendingInfoExtended } from './bending/types';
import { getInspectionConfig } from '../actions';
import type { InspectionConfigData } from '../actions';
export type { InspectionContentRef };
@@ -375,10 +377,60 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
)?.id ?? null;
}, [isBending, template.columns]);
// ===== inspection-config API 연동 =====
const [inspectionConfig, setInspectionConfig] = useState<InspectionConfigData | null>(null);
useEffect(() => {
if (!isBending || !order.id) return;
let cancelled = false;
getInspectionConfig(order.id).then(result => {
if (!cancelled && result.success && result.data) {
setInspectionConfig(result.data);
}
});
return () => { cancelled = true; };
}, [isBending, order.id]);
const bendingProducts = useMemo(() => {
if (!isBending) return [];
// API 응답이 있고 items가 있으면 API 기반 구성품 사용
if (inspectionConfig?.items?.length) {
const productCode = inspectionConfig.product_code || '';
// bending_info에서 dimension 보조 데이터 추출
const bi = order.bendingInfo as BendingInfoExtended | undefined;
const wallLen = bi?.guideRail?.wall?.lengthData?.[0]?.length;
const sideLen = bi?.guideRail?.side?.lengthData?.[0]?.length;
return inspectionConfig.items.map((item): BendingProduct => {
// API id → 표시용 매핑 (이름, 타입, 치수)
const displayMap: Record<string, { name: string; type: string; len: string; wid: string }> = {
guide_rail_wall: { name: '가이드레일', type: '벽면형', len: String(wallLen || 3500), wid: 'N/A' },
guide_rail_side: { name: '가이드레일', type: '측면형', len: String(sideLen || 3000), wid: 'N/A' },
bottom_bar: { name: '하단마감재', type: '60×40', len: '3000', wid: 'N/A' },
case_box: { name: '케이스', type: '양면', len: '3000', wid: 'N/A' },
smoke_w50: { name: '연기차단재', type: '화이바 W50\n가이드레일용', len: '-', wid: '50' },
smoke_w80: { name: '연기차단재', type: '화이바 W80\n케이스용', len: '-', wid: '80' },
};
const d = displayMap[item.id] || { name: item.name, type: '', len: '-', wid: 'N/A' };
return {
id: item.id,
category: productCode,
productName: d.name,
productType: d.type,
lengthDesign: d.len,
widthDesign: d.wid,
gapPoints: item.gap_points.map(gp => ({
point: gp.point,
designValue: gp.design_value,
})),
};
});
}
// fallback: 기존 프론트 로직 사용
return buildBendingProducts(order);
}, [isBending, order]);
}, [isBending, order, inspectionConfig]);
const bendingExpandedRows = useMemo(() => {
if (!isBending) return [];
@@ -740,8 +792,8 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
});
}
// 비-Bending 모드: 개소(WorkItem)별 데이터
if (!isBending) effectiveWorkItems.forEach((wi, rowIdx) => {
// 개소(WorkItem)별 데이터: 비-Bending 또는 Bending이지만 구성품이 없는 경우 (AS-IS)
if (!isBending || bendingProducts.length === 0) effectiveWorkItems.forEach((wi, rowIdx) => {
for (const col of template.columns) {
// 일련번호 컬럼 → 저장 (mng show에서 표시용)
if (isSerialColumn(col.label)) {

View File

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

View File

@@ -38,6 +38,7 @@ interface WorkOrderApiItem {
sales_order?: {
id: number;
order_no: string;
item?: { id: number; code: string; name: string } | null;
client?: { id: number; name: string };
client_contact?: string;
options?: { manager_name?: string; [key: string]: unknown };
@@ -50,6 +51,8 @@ interface WorkOrderApiItem {
items?: {
id: number;
item_name: string;
item_id?: number | null;
item?: { id: number; code: string; name: string } | null;
quantity: number;
specification?: string | null;
options?: Record<string, unknown> | null;
@@ -89,7 +92,8 @@ function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
// ===== API → WorkOrder 변환 =====
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
const productName = api.items?.[0]?.item_name || '-';
const productCode = (api.items?.[0]?.options?.product_code as string) || api.sales_order?.item?.code || '-';
const productName = (api.items?.[0]?.options?.product_name as string) || api.items?.[0]?.item_name || '-';
// 납기일 계산 (지연 여부)
const dueDate = api.scheduled_date || '';
@@ -173,6 +177,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
return {
id: String(api.id),
orderNo: api.work_order_no,
productCode,
productName,
processCode: processInfo.code,
processName: processInfo.name,

View File

@@ -714,7 +714,7 @@ export default function WorkerScreen() {
workOrderId: selectedOrder.id,
itemNo: index + 1,
itemCode: selectedOrder.orderNo || '-',
itemName: itemSummary,
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${itemSummary}`,
floor: (opts.floor as string) || '-',
code: (opts.code as string) || '-',
width: (opts.width as number) || 0,
@@ -774,7 +774,7 @@ export default function WorkerScreen() {
workOrderId: selectedOrder.id,
itemNo: 1,
itemCode: selectedOrder.orderNo || '-',
itemName: selectedOrder.productName || '-',
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${selectedOrder.productName || '-'}`,
floor: '-',
code: '-',
width: 0,
@@ -940,6 +940,7 @@ export default function WorkerScreen() {
const syntheticOrder: WorkOrder = {
id: item.id,
orderNo: item.itemCode,
productCode: item.itemCode,
productName: item.itemName,
processCode: item.processType,
processName: PROCESS_TAB_LABELS[item.processType],
@@ -999,6 +1000,7 @@ export default function WorkerScreen() {
const syntheticOrder: WorkOrder = {
id: mockItem.id,
orderNo: mockItem.itemCode,
productCode: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],
@@ -1239,6 +1241,7 @@ export default function WorkerScreen() {
return {
id: mockItem.id,
orderNo: mockItem.itemCode,
productCode: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],