feat: [전자결재] 결재함 기능 확장 및 연결문서 기능 추가
- ApprovalBox actions/타입 확장 - DocumentDetailModalV2 개선 - LinkedDocumentContent 신규 추가 - 결재 문서 타입 보강 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,7 @@ function mapTabToApiStatus(tabStatus: string): string | undefined {
|
|||||||
|
|
||||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||||
const typeMap: Record<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';
|
return typeMap[formCategory || ''] || 'proposal';
|
||||||
}
|
}
|
||||||
@@ -176,6 +176,127 @@ export async function approveDocumentsBulk(ids: string[], comment?: string): Pro
|
|||||||
return { success: true };
|
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 }> {
|
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||||
const failedIds: string[] = [];
|
const failedIds: string[] = [];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
rejectDocument,
|
rejectDocument,
|
||||||
approveDocumentsBulk,
|
approveDocumentsBulk,
|
||||||
rejectDocumentsBulk,
|
rejectDocumentsBulk,
|
||||||
|
getDocumentApprovalById,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -58,6 +59,7 @@ import type {
|
|||||||
ProposalDocumentData,
|
ProposalDocumentData,
|
||||||
ExpenseReportDocumentData,
|
ExpenseReportDocumentData,
|
||||||
ExpenseEstimateDocumentData,
|
ExpenseEstimateDocumentData,
|
||||||
|
LinkedDocumentData,
|
||||||
} from '@/components/approval/DocumentDetail/types';
|
} from '@/components/approval/DocumentDetail/types';
|
||||||
import type {
|
import type {
|
||||||
ApprovalTabType,
|
ApprovalTabType,
|
||||||
@@ -76,6 +78,7 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
|
||||||
|
|
||||||
// ===== 통계 타입 =====
|
// ===== 통계 타입 =====
|
||||||
interface InboxSummary {
|
interface InboxSummary {
|
||||||
@@ -111,9 +114,13 @@ export function ApprovalBox() {
|
|||||||
// ===== 문서 상세 모달 상태 =====
|
// ===== 문서 상세 모달 상태 =====
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
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);
|
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||||
|
|
||||||
|
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
|
||||||
|
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||||
|
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
|
||||||
|
|
||||||
// API 데이터
|
// API 데이터
|
||||||
const [data, setData] = useState<ApprovalRecord[]>([]);
|
const [data, setData] = useState<ApprovalRecord[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
@@ -288,6 +295,27 @@ export function ApprovalBox() {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
|
||||||
try {
|
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));
|
const result = await getApprovalById(parseInt(item.id));
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const formData = result.data;
|
const formData = result.data;
|
||||||
@@ -439,6 +467,8 @@ export function ApprovalBox() {
|
|||||||
return 'expenseEstimate';
|
return 'expenseEstimate';
|
||||||
case 'expense_report':
|
case 'expense_report':
|
||||||
return 'expenseReport';
|
return 'expenseReport';
|
||||||
|
case 'document':
|
||||||
|
return 'document';
|
||||||
default:
|
default:
|
||||||
return 'proposal';
|
return 'proposal';
|
||||||
}
|
}
|
||||||
@@ -796,6 +826,19 @@ export function ApprovalBox() {
|
|||||||
onReject={canApprove ? handleModalReject : undefined}
|
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,
|
handleModalApprove,
|
||||||
handleModalReject,
|
handleModalReject,
|
||||||
canApprove,
|
canApprove,
|
||||||
|
isInspectionModalOpen,
|
||||||
|
inspectionWorkOrderId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
|
|||||||
// 결재 상태
|
// 결재 상태
|
||||||
export type ApprovalStatus = '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 }[] = [
|
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '전체' },
|
||||||
{ value: 'expense_report', label: '지출결의서' },
|
{ value: 'expense_report', label: '지출결의서' },
|
||||||
{ value: 'proposal', label: '품의서' },
|
{ value: 'proposal', label: '품의서' },
|
||||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||||
|
{ value: 'document', label: '문서 결재' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 정렬 옵션
|
// 정렬 옵션
|
||||||
@@ -71,12 +72,14 @@ export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
|||||||
expense_report: '지출결의서',
|
expense_report: '지출결의서',
|
||||||
proposal: '품의서',
|
proposal: '품의서',
|
||||||
expense_estimate: '지출예상내역서',
|
expense_estimate: '지출예상내역서',
|
||||||
|
document: '문서 결재',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||||
expense_report: 'blue',
|
expense_report: 'blue',
|
||||||
proposal: 'green',
|
proposal: 'green',
|
||||||
expense_estimate: 'purple',
|
expense_estimate: 'purple',
|
||||||
|
document: 'orange',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { DocumentViewer } from '@/components/document-system';
|
|||||||
import { ProposalDocument } from './ProposalDocument';
|
import { ProposalDocument } from './ProposalDocument';
|
||||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||||
|
import { LinkedDocumentContent } from './LinkedDocumentContent';
|
||||||
import type {
|
import type {
|
||||||
DocumentType,
|
DocumentType,
|
||||||
DocumentDetailModalProps,
|
DocumentDetailModalProps,
|
||||||
ProposalDocumentData,
|
ProposalDocumentData,
|
||||||
ExpenseReportDocumentData,
|
ExpenseReportDocumentData,
|
||||||
ExpenseEstimateDocumentData,
|
ExpenseEstimateDocumentData,
|
||||||
|
LinkedDocumentData,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +43,8 @@ export function DocumentDetailModalV2({
|
|||||||
return '지출결의서';
|
return '지출결의서';
|
||||||
case 'expenseEstimate':
|
case 'expenseEstimate':
|
||||||
return '지출 예상 내역서';
|
return '지출 예상 내역서';
|
||||||
|
case 'document':
|
||||||
|
return (data as LinkedDocumentData).templateName || '문서 결재';
|
||||||
default:
|
default:
|
||||||
return '문서';
|
return '문서';
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,8 @@ export function DocumentDetailModalV2({
|
|||||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||||
case 'expenseEstimate':
|
case 'expenseEstimate':
|
||||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||||
|
case 'document':
|
||||||
|
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
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 {
|
export interface Approver {
|
||||||
@@ -72,6 +72,29 @@ export interface ExpenseEstimateDocumentData {
|
|||||||
drafter: Approver;
|
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';
|
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
|
||||||
|
|
||||||
@@ -83,7 +106,7 @@ export interface DocumentDetailModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
documentType: DocumentType;
|
documentType: DocumentType;
|
||||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
|
||||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
||||||
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user