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:
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user