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

@@ -22,8 +22,9 @@ import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
interface InboxSummary {
total: number;
pending: number;
approved: number;
requested: number;
scheduled: number;
completed: number;
rejected: number;
}
@@ -56,14 +57,16 @@ interface InboxStepApiData {
function mapApiStatus(apiStatus: string): ApprovalStatus {
const statusMap: Record<string, ApprovalStatus> = {
'pending': 'pending', 'approved': 'approved', 'rejected': 'rejected',
'requested': 'requested', 'scheduled': 'scheduled', 'completed': 'completed', 'rejected': 'rejected',
// legacy fallback
'pending': 'requested', 'approved': 'completed',
};
return statusMap[apiStatus] || 'pending';
return statusMap[apiStatus] || 'requested';
}
function mapTabToApiStatus(tabStatus: string): string | undefined {
const statusMap: Record<string, string> = {
'pending': 'requested', 'approved': 'completed', 'rejected': 'rejected',
'requested': 'requested', 'scheduled': 'scheduled', 'completed': 'completed', 'rejected': 'rejected',
};
return statusMap[tabStatus];
}
@@ -78,6 +81,7 @@ function mapApprovalType(formCategory?: string): ApprovalType {
function mapDocumentStatus(status: string): string {
const statusMap: Record<string, string> = {
'pending': '진행중', 'approved': '완료', 'rejected': '반려',
'cancelled': '회수', 'on_hold': '보류',
};
return statusMap[status] || '진행중';
}
@@ -91,6 +95,8 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
id: String(data.id),
documentNo: data.document_number,
approvalType: mapApprovalType(data.form?.category),
formCode: data.form?.code,
formName: data.form?.name,
documentStatus: mapDocumentStatus(data.status),
title: data.title,
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
@@ -300,6 +306,37 @@ export async function getDocumentApprovalById(id: number): Promise<{
};
}
// ============================================
// 워크플로우 액션 (보류/보류해제/전결)
// ============================================
export async function holdDocument(id: string, comment?: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/approvals/${id}/hold`),
method: 'POST',
body: { comment: comment || '' },
errorMessage: '보류 처리에 실패했습니다.',
});
}
export async function releaseHoldDocument(id: string, comment?: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/approvals/${id}/release-hold`),
method: 'POST',
body: { comment: comment || '' },
errorMessage: '보류 해제에 실패했습니다.',
});
}
export async function preDecideDocument(id: string, comment?: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/approvals/${id}/pre-decide`),
method: 'POST',
body: { comment: comment || '' },
errorMessage: '전결 처리에 실패했습니다.',
});
}
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

@@ -27,13 +27,6 @@ import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@@ -59,7 +52,10 @@ import type {
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
DynamicDocumentData,
} from '@/components/approval/DocumentDetail/types';
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types';
import type {
ApprovalTabType,
ApprovalRecord,
@@ -105,7 +101,8 @@ export function ApprovalBox() {
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | DynamicDocumentData | null>(null);
const [modalDocTypeOverride, setModalDocTypeOverride] = useState<DocumentType | null>(null);
const [, setIsModalLoading] = useState(false);
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
@@ -120,7 +117,7 @@ export function ApprovalBox() {
const isInitialLoadDone = useRef(false);
// 통계 데이터
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
const [fixedStats, setFixedStats] = useState({ all: 0, requested: 0, scheduled: 0, completed: 0, rejected: 0 });
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
@@ -186,14 +183,16 @@ export function ApprovalBox() {
// ===== 전체 탭일 때만 통계 업데이트 =====
useEffect(() => {
if (activeTab === 'all' && data.length > 0) {
const pending = data.filter((item) => item.status === 'pending').length;
const approved = data.filter((item) => item.status === 'approved').length;
const requested = data.filter((item) => item.status === 'requested').length;
const scheduled = data.filter((item) => item.status === 'scheduled').length;
const completed = data.filter((item) => item.status === 'completed').length;
const rejected = data.filter((item) => item.status === 'rejected').length;
setFixedStats({
all: totalCount,
pending,
approved,
requested,
scheduled,
completed,
rejected,
});
}
@@ -308,11 +307,11 @@ export function ApprovalBox() {
return;
}
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
// 결재 문서 조회 (품의서, 지출결의서, 비용견적서, 전용 양식 등)
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
const docType = getDocumentType(item.approvalType);
const docTypeCode = formData.basicInfo.documentType;
// 기안자 정보
const drafter = {
@@ -330,7 +329,7 @@ export function ApprovalBox() {
position: person.position,
department: person.department,
status:
item.status === 'approved'
item.status === 'completed'
? ('approved' as const)
: item.status === 'rejected'
? ('rejected' as const)
@@ -339,10 +338,52 @@ export function ApprovalBox() {
: ('none' as const),
}));
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData;
switch (docType) {
// 전용 양식 (14종) 또는 동적 양식 → DynamicDocumentData
const isDedicated = (DEDICATED_FORM_CODES as readonly string[]).includes(docTypeCode)
&& docTypeCode !== 'proposal' && docTypeCode !== 'expenseReport' && docTypeCode !== 'expense_report'
&& docTypeCode !== 'expenseEstimate' && docTypeCode !== 'expense_estimate';
const isDynamic = !isDedicated && docTypeCode !== 'proposal' && docTypeCode !== 'expenseReport'
&& docTypeCode !== 'expense_report' && docTypeCode !== 'expenseEstimate' && docTypeCode !== 'expense_estimate';
if (isDedicated || isDynamic) {
// 전용 양식의 데이터를 content에서 추출
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 || {});
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
formName: formData.basicInfo.formName || getFormName(docTypeCode),
fields,
fieldLabels: getFieldLabels(docTypeCode),
approvers,
drafter,
};
setModalDocTypeOverride('dynamic');
setModalData(convertedData);
return;
}
switch (docTypeCode) {
case 'expenseEstimate':
case 'expense_estimate':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
@@ -360,8 +401,10 @@ export function ApprovalBox() {
approvers,
drafter,
};
setModalDocTypeOverride('expenseEstimate');
break;
case 'expenseReport':
case 'expense_report':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
@@ -380,6 +423,7 @@ export function ApprovalBox() {
approvers,
drafter,
};
setModalDocTypeOverride('expenseReport');
break;
default: {
// 품의서
@@ -399,6 +443,7 @@ export function ApprovalBox() {
approvers,
drafter,
};
setModalDocTypeOverride('proposal');
break;
}
}
@@ -477,15 +522,21 @@ export function ApprovalBox() {
color: 'blue',
},
{
value: 'pending',
label: APPROVAL_TAB_LABELS.pending,
count: fixedStats.pending,
value: 'requested',
label: APPROVAL_TAB_LABELS.requested,
count: fixedStats.requested,
color: 'yellow',
},
{
value: 'approved',
label: APPROVAL_TAB_LABELS.approved,
count: fixedStats.approved,
value: 'scheduled',
label: APPROVAL_TAB_LABELS.scheduled,
count: fixedStats.scheduled,
color: 'blue',
},
{
value: 'completed',
label: APPROVAL_TAB_LABELS.completed,
count: fixedStats.completed,
color: 'green',
},
{
@@ -587,14 +638,20 @@ export function ApprovalBox() {
iconColor: 'text-blue-500',
},
{
label: '결재',
value: `${fixedStats.pending}`,
label: '결재 요청',
value: `${fixedStats.requested}`,
icon: Clock,
iconColor: 'text-yellow-500',
},
{
label: '결재완료',
value: `${fixedStats.approved}`,
label: '예정',
value: `${fixedStats.scheduled}`,
icon: Clock,
iconColor: 'text-blue-500',
},
{
label: '처리 완료',
value: `${fixedStats.completed}`,
icon: FileCheck,
iconColor: 'text-green-500',
},
@@ -627,42 +684,6 @@ export function ApprovalBox() {
</>
) : null,
tableHeaderActions: (
<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>
),
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
@@ -723,7 +744,7 @@ export function ApprovalBox() {
</div>
}
actions={
item.status === 'pending' && isSelected && canApprove ? (
item.status === 'requested' && isSelected && canApprove ? (
<div className="flex gap-2">
<Button
variant="default"
@@ -809,9 +830,10 @@ export function ApprovalBox() {
setIsModalOpen(open);
if (!open) {
setModalData(null);
setModalDocTypeOverride(null);
}
}}
documentType={getDocumentType(selectedDocument.approvalType)}
documentType={modalDocTypeOverride || getDocumentType(selectedDocument.approvalType)}
data={modalData}
mode="inbox"
onEdit={handleModalEdit}

View File

@@ -4,12 +4,12 @@
*/
// ===== 메인 탭 타입 =====
export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
export type ApprovalTabType = 'all' | 'requested' | 'scheduled' | 'completed' | 'rejected';
// 결재 상태
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
export type ApprovalStatus = 'requested' | 'scheduled' | 'completed' | 'rejected';
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
// 결재 유형 (문서 종류): 지출결의서, 품의서, 비용견적서, 문서결재
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
// 필터 옵션
@@ -19,7 +19,7 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'expense_report', label: '지출결의서' },
{ value: 'proposal', label: '품의서' },
{ value: 'expense_estimate', label: '지출예상내역서' },
{ value: 'expense_estimate', label: '비용견적서' },
{ value: 'document', label: '문서 결재' },
];
@@ -38,6 +38,8 @@ export interface ApprovalRecord {
id: string;
documentNo: string; // 문서번호
approvalType: ApprovalType; // 결재유형 (휴가, 경비 등)
formCode?: string; // 양식코드
formName?: string; // 양식명
documentStatus: string; // 문서상태
title: string; // 제목
draftDate: string; // 기안일
@@ -63,15 +65,16 @@ export interface ApprovalFormData {
export const APPROVAL_TAB_LABELS: Record<ApprovalTabType, string> = {
all: '전체결재',
pending: '결재',
approved: '결재완료',
rejected: '결재반려',
requested: '결재 요청',
scheduled: '예정',
completed: '처리 완료',
rejected: '반려',
};
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
expense_report: '지출결의서',
proposal: '품의서',
expense_estimate: '지출예상내역서',
expense_estimate: '비용견적서',
document: '문서 결재',
};
@@ -83,13 +86,15 @@ export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
};
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
pending: '대기',
approved: '승인',
requested: '결재 요청',
scheduled: '예정',
completed: '처리 완료',
rejected: '반려',
};
export const APPROVAL_STATUS_COLORS: Record<ApprovalStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
requested: 'bg-yellow-100 text-yellow-800',
scheduled: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
};