feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링
- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서 - 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선 - HR: 근태/휴가/직원 소소한 수정 - vehicle/quality/pricing 마이너 수정 - approval_backup_v1 백업 보관
This commit is contained in:
@@ -19,13 +19,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -36,7 +29,10 @@ import {
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types';
|
||||
import type {
|
||||
ReferenceTabType,
|
||||
ReferenceRecord,
|
||||
@@ -82,7 +78,10 @@ export function ReferenceBox() {
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
|
||||
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ReferenceRecord[]>([]);
|
||||
@@ -284,86 +283,125 @@ export function ReferenceBox() {
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||||
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
|
||||
const handleDocumentClick = useCallback(async (item: ReferenceRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalLoading(true);
|
||||
setIsModalOpen(true);
|
||||
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docTypeCode = formData.basicInfo.documentType;
|
||||
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: formData.basicInfo.drafter,
|
||||
position: formData.basicInfo.drafterPosition || '',
|
||||
department: formData.basicInfo.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = formData.approvalLine.map((person, index) => ({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status: index === 0 ? ('approved' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
// 전용 양식 또는 동적 양식 → DynamicDocumentData
|
||||
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
|
||||
|
||||
if (!isBuiltin) {
|
||||
const dedicatedDataMap: Record<string, unknown> = {
|
||||
officialDocument: formData.officialDocumentData,
|
||||
resignation: formData.resignationData,
|
||||
employmentCert: formData.employmentCertData,
|
||||
careerCert: formData.careerCertData,
|
||||
appointmentCert: formData.appointmentCertData,
|
||||
sealUsage: formData.sealUsageData,
|
||||
leaveNotice1st: formData.leaveNotice1stData,
|
||||
leaveNotice2nd: formData.leaveNotice2ndData,
|
||||
powerOfAttorney: formData.powerOfAttorneyData,
|
||||
boardMinutes: formData.boardMinutesData,
|
||||
quotation: formData.quotationData,
|
||||
};
|
||||
const dedicatedData = dedicatedDataMap[docTypeCode];
|
||||
const fields = dedicatedData
|
||||
? filterVisibleFields(dedicatedData as Record<string, unknown>)
|
||||
: (formData.dynamicFormData || {});
|
||||
|
||||
setModalDocType('dynamic');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
formName: formData.basicInfo.formName || getFormName(docTypeCode),
|
||||
fields,
|
||||
fieldLabels: getFieldLabels(docTypeCode),
|
||||
approvers,
|
||||
drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
|
||||
setModalDocType('expenseEstimate');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
items: formData.expenseEstimateData?.items.map(i => ({
|
||||
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
|
||||
amount: i.amount, vendor: i.vendor, account: i.memo || '',
|
||||
})) || [],
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
||||
approvers, drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
|
||||
setModalDocType('expenseReport');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
requestDate: formData.expenseReportData?.requestDate || '',
|
||||
paymentDate: formData.expenseReportData?.paymentDate || '',
|
||||
items: formData.expenseReportData?.items.map((i, idx) => ({
|
||||
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
|
||||
})) || [],
|
||||
cardInfo: formData.expenseReportData?.cardId || '-',
|
||||
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
||||
attachments: [], approvers, drafter,
|
||||
});
|
||||
} else {
|
||||
// 품의서
|
||||
setModalDocType('proposal');
|
||||
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
vendor: formData.proposalData?.vendor || '-',
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
||||
title: formData.proposalData?.title || item.title,
|
||||
description: formData.proposalData?.description || '-',
|
||||
reason: formData.proposalData?.reason || '-',
|
||||
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
||||
attachments: uploadedFileUrls,
|
||||
approvers, drafter,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
} finally {
|
||||
setIsModalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== ApprovalType → DocumentType 변환 =====
|
||||
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
||||
switch (approvalType) {
|
||||
case 'expense_estimate': return 'expenseEstimate';
|
||||
case 'expense_report': return 'expenseReport';
|
||||
default: return 'proposal';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== ReferenceRecord → 모달용 데이터 변환 =====
|
||||
const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item.approvalType);
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: item.drafterPosition,
|
||||
department: item.drafterDepartment,
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = [{
|
||||
id: 'approver-1',
|
||||
name: '결재자',
|
||||
position: '부장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved' as const,
|
||||
}];
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
||||
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
||||
],
|
||||
totalExpense: 3050000,
|
||||
accountBalance: 25000000,
|
||||
finalDifference: 21950000,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
requestDate: item.draftDate,
|
||||
paymentDate: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
|
||||
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
|
||||
],
|
||||
cardInfo: '삼성카드 **** 1234',
|
||||
totalAmount: 80000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
vendor: '거래처',
|
||||
vendorPaymentDate: item.draftDate,
|
||||
title: item.title,
|
||||
description: item.title,
|
||||
reason: '업무상 필요',
|
||||
estimatedCost: 1000000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||||
@@ -390,39 +428,6 @@ export function ReferenceBox() {
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
||||
const tableHeaderActions = useMemo(() => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
), [filterOption, sortOption]);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const referenceBoxConfig: UniversalListConfig<ReferenceRecord> = useMemo(() => ({
|
||||
title: '참조함',
|
||||
@@ -475,8 +480,6 @@ export function ReferenceBox() {
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
tableHeaderActions: tableHeaderActions,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
@@ -619,12 +622,15 @@ export function ReferenceBox() {
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
{selectedDocument && modalData && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setModalData(null);
|
||||
}}
|
||||
documentType={modalDocType}
|
||||
data={modalData}
|
||||
mode="reference"
|
||||
/>
|
||||
)}
|
||||
@@ -640,7 +646,8 @@ export function ReferenceBox() {
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
tableHeaderActions,
|
||||
filterOption,
|
||||
sortOption,
|
||||
handleMarkReadClick,
|
||||
handleMarkUnreadClick,
|
||||
handleDocumentClick,
|
||||
@@ -651,8 +658,8 @@ export function ReferenceBox() {
|
||||
handleMarkUnreadConfirm,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
getDocumentType,
|
||||
convertToModalData,
|
||||
modalData,
|
||||
modalDocType,
|
||||
]);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
|
||||
Reference in New Issue
Block a user