feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링

- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서
- 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선
- HR: 근태/휴가/직원 소소한 수정
- vehicle/quality/pricing 마이너 수정
- approval_backup_v1 백업 보관
This commit is contained in:
유병철
2026-03-16 17:06:02 +09:00
parent 1280c8d61a
commit 0029988e6f
91 changed files with 13202 additions and 1025 deletions

View File

@@ -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,
]);
// 모바일 필터 변경 핸들러