feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링
- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서 - 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선 - HR: 근태/휴가/직원 소소한 수정 - vehicle/quality/pricing 마이너 수정 - approval_backup_v1 백업 보관
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user