feat: [결재] 결재함에서 검사성적서 템플릿 기반 렌더링 + 결재 상신 기능

- 결재함에서 work_order 연결 문서 클릭 시 InspectionReportModal(readOnly)로 표시
  - 기존 LinkedDocumentContent(key-value)가 아닌 템플릿 기반 검사성적서 형태로 표시
  - getDocumentApprovalById에서 document.linkable_type/linkable_id로 workOrderId 추출
  - field_value 컬럼명 매칭 수정 (d.value → d.field_value ?? d.value)
- InspectionReportModal에 결재 상신 버튼 추가 (DRAFT 상태에서만 표시)
- submitDocumentForApproval 서버 액션 추가
- LinkedDocumentContent 컴포넌트 신규 (일반 문서용 폴백)
- DocumentType에 'document' 타입 추가, LinkedDocumentData 인터페이스 신규
This commit is contained in:
2026-02-27 21:07:12 +09:00
parent 0f53b407db
commit d1c530fdc1
8 changed files with 434 additions and 17 deletions

View File

@@ -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[] = [];

View File

@@ -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,
]
);

View File

@@ -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> = {