merge: main의 검사문서/생산/결재 커밋을 develop으로 이동
This commit is contained in:
@@ -107,3 +107,10 @@ fixed_tools: []
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
@@ -70,7 +70,7 @@ function mapTabToApiStatus(tabStatus: string): string | undefined {
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
@@ -176,6 +176,127 @@ export async function approveDocumentsBulk(ids: string[], comment?: string): Pro
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 연결 문서(Document) 조회
|
||||
// ============================================
|
||||
|
||||
interface LinkedDocumentApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
drafter?: {
|
||||
id: number; name: string; position?: string;
|
||||
department?: { name: string };
|
||||
tenant_profile?: { position_key?: string; department?: { name: string } };
|
||||
};
|
||||
steps?: InboxStepApiData[];
|
||||
linkable?: {
|
||||
id: number;
|
||||
title: string;
|
||||
document_no: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
linkable_type?: string;
|
||||
linkable_id?: number;
|
||||
template?: { id: number; name: string; code: string };
|
||||
data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>;
|
||||
approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>;
|
||||
attachments?: Array<{ id: number; display_name: string; file_path: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkedDocumentResult {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>;
|
||||
approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>;
|
||||
drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' };
|
||||
attachments?: Array<{ id: number; name: string; url: string }>;
|
||||
}
|
||||
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장',
|
||||
'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
export async function getDocumentApprovalById(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: LinkedDocumentResult;
|
||||
error?: string;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const apiData = result.data as LinkedDocumentApiData;
|
||||
const linkable = apiData.linkable;
|
||||
|
||||
const drafter = {
|
||||
id: String(apiData.drafter?.id || ''),
|
||||
name: apiData.drafter?.name || '',
|
||||
position: apiData.drafter?.tenant_profile?.position_key
|
||||
? getPositionLabel(apiData.drafter.tenant_profile.position_key)
|
||||
: (apiData.drafter?.position || ''),
|
||||
department: apiData.drafter?.tenant_profile?.department?.name
|
||||
|| apiData.drafter?.department?.name || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
|
||||
const approvers = (apiData.steps || [])
|
||||
.filter(s => s.step_type === 'approval' || s.step_type === 'agreement')
|
||||
.map(step => ({
|
||||
id: String(step.approver?.id || step.approver_id),
|
||||
name: step.approver?.name || '',
|
||||
position: step.approver?.position || '',
|
||||
department: step.approver?.department?.name || '',
|
||||
status: (step.status === 'approved' ? 'approved'
|
||||
: step.status === 'rejected' ? 'rejected'
|
||||
: step.status === 'pending' ? 'pending'
|
||||
: 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||
}));
|
||||
|
||||
// work_order 연결 문서인 경우 workOrderId 추출
|
||||
const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
documentNo: linkable?.document_no || apiData.document_number,
|
||||
createdAt: linkable?.created_at || '',
|
||||
title: linkable?.title || apiData.title,
|
||||
templateName: linkable?.template?.name || '',
|
||||
templateCode: linkable?.template?.code || '',
|
||||
status: linkable?.status || apiData.status,
|
||||
workOrderId,
|
||||
documentData: (linkable?.data || []).map(d => ({
|
||||
fieldKey: d.field_key,
|
||||
fieldLabel: d.field_label || d.field_key,
|
||||
value: d.field_value ?? d.value,
|
||||
})),
|
||||
approvers,
|
||||
drafter,
|
||||
attachments: (linkable?.attachments || []).map(a => ({
|
||||
id: a.id,
|
||||
name: a.display_name,
|
||||
url: `/api/proxy/files/${a.id}/download`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
const failedIds: string[] = [];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
rejectDocument,
|
||||
approveDocumentsBulk,
|
||||
rejectDocumentsBulk,
|
||||
getDocumentApprovalById,
|
||||
} from './actions';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -58,6 +59,7 @@ import type {
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
ApprovalTabType,
|
||||
@@ -76,6 +78,7 @@ import {
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface InboxSummary {
|
||||
@@ -111,9 +114,13 @@ export function ApprovalBox() {
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
|
||||
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ApprovalRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
@@ -288,6 +295,27 @@ export function ApprovalBox() {
|
||||
setIsModalOpen(true);
|
||||
|
||||
try {
|
||||
// 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회
|
||||
if (item.approvalType === 'document') {
|
||||
const result = await getDocumentApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
// work_order 연결 문서 → InspectionReportModal로 열기
|
||||
if (result.data.workOrderId) {
|
||||
setIsModalOpen(false);
|
||||
setIsModalLoading(false);
|
||||
setInspectionWorkOrderId(String(result.data.workOrderId));
|
||||
setIsInspectionModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setModalData(result.data as LinkedDocumentData);
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
@@ -439,6 +467,8 @@ export function ApprovalBox() {
|
||||
return 'expenseEstimate';
|
||||
case 'expense_report':
|
||||
return 'expenseReport';
|
||||
case 'document':
|
||||
return 'document';
|
||||
default:
|
||||
return 'proposal';
|
||||
}
|
||||
@@ -796,6 +826,19 @@ export function ApprovalBox() {
|
||||
onReject={canApprove ? handleModalReject : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검사성적서 모달 (work_order 연결 문서) */}
|
||||
<InspectionReportModal
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsInspectionModalOpen(open);
|
||||
if (!open) {
|
||||
setInspectionWorkOrderId(null);
|
||||
}
|
||||
}}
|
||||
workOrderId={inspectionWorkOrderId}
|
||||
readOnly={true}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
@@ -827,6 +870,8 @@ export function ApprovalBox() {
|
||||
handleModalApprove,
|
||||
handleModalReject,
|
||||
canApprove,
|
||||
isInspectionModalOpen,
|
||||
inspectionWorkOrderId,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -9,17 +9,18 @@ export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
|
||||
// 결재 상태
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
{ value: 'document', label: '문서 결재' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
@@ -71,12 +72,14 @@ export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
document: '문서 결재',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||
expense_report: 'blue',
|
||||
proposal: 'green',
|
||||
expense_estimate: 'purple',
|
||||
document: 'orange',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
|
||||
@@ -4,12 +4,14 @@ import { DocumentViewer } from '@/components/document-system';
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { LinkedDocumentContent } from './LinkedDocumentContent';
|
||||
import type {
|
||||
DocumentType,
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -41,6 +43,8 @@ export function DocumentDetailModalV2({
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
case 'document':
|
||||
return (data as LinkedDocumentData).templateName || '문서 결재';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
@@ -69,6 +73,8 @@ export function DocumentDetailModalV2({
|
||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
case 'document':
|
||||
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
133
src/components/approval/DocumentDetail/LinkedDocumentContent.tsx
Normal file
133
src/components/approval/DocumentDetail/LinkedDocumentContent.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 연결 문서 콘텐츠 컴포넌트
|
||||
*
|
||||
* 문서관리에서 생성된 검사 성적서, 작업일지 등을
|
||||
* 결재함 모달에서 렌더링합니다.
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { LinkedDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Paperclip } from 'lucide-react';
|
||||
|
||||
interface LinkedDocumentContentProps {
|
||||
data: LinkedDocumentData;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '임시저장',
|
||||
PENDING: '진행중',
|
||||
APPROVED: '승인완료',
|
||||
REJECTED: '반려',
|
||||
CANCELLED: '회수',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title={data.templateName || '문서 결재'}
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt?.substring(0, 10) || '-'}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 기본 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
문서 제목
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
양식
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-500" />
|
||||
{data.templateName || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
상태
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">
|
||||
<Badge className={STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'}>
|
||||
{STATUS_LABELS[data.status] || data.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 데이터 */}
|
||||
{data.documentData.length > 0 && (
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
문서 내용
|
||||
</div>
|
||||
<div className="divide-y divide-gray-300">
|
||||
{data.documentData.map((field, index) => (
|
||||
<div key={index} className="flex">
|
||||
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
{field.fieldLabel}
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm whitespace-pre-wrap">
|
||||
{renderFieldValue(field.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{data.attachments && data.attachments.length > 0 && (
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
첨부파일
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{data.attachments.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{file.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'string') return value || '-';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'boolean') return value ? '예' : '아니오';
|
||||
if (Array.isArray(value)) return value.join(', ') || '-';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// ===== 문서 상세 모달 타입 정의 =====
|
||||
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
|
||||
|
||||
// 결재자 정보
|
||||
export interface Approver {
|
||||
@@ -72,6 +72,29 @@ export interface ExpenseEstimateDocumentData {
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
|
||||
export interface LinkedDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{
|
||||
fieldKey: string;
|
||||
fieldLabel: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
attachments?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 문서 상세 모달 모드
|
||||
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
|
||||
|
||||
@@ -83,7 +106,7 @@ export interface DocumentDetailModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentType;
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
|
||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
||||
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
||||
onEdit?: () => void;
|
||||
|
||||
@@ -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 || '-',
|
||||
|
||||
@@ -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; // 공정명 (슬랫, 스크린, 절곡, ...)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 있을 때)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user