- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경 - 매출채권 섹션: transformer/타입 정비 - 캘린더 섹션: ScheduleDetailModal 개선 - 카드관리 모달 transformer 확장 - useCEODashboard 훅 리팩토링 및 정리 - dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장 - 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선 - ApprovalBox 소폭 수정 - CLAUDE.md 업데이트
909 lines
30 KiB
TypeScript
909 lines
30 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useDateRange } from '@/hooks';
|
|
import {
|
|
FileCheck,
|
|
Check,
|
|
X,
|
|
Clock,
|
|
FileX,
|
|
Files,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
getInbox,
|
|
getInboxSummary,
|
|
approveDocument,
|
|
rejectDocument,
|
|
approveDocumentsBulk,
|
|
rejectDocumentsBulk,
|
|
getDocumentApprovalById,
|
|
} from './actions';
|
|
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
|
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 { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type TabOption,
|
|
} from '@/components/templates/UniversalListPage';
|
|
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,
|
|
LinkedDocumentData,
|
|
} from '@/components/approval/DocumentDetail/types';
|
|
import type {
|
|
ApprovalTabType,
|
|
ApprovalRecord,
|
|
ApprovalType,
|
|
SortOption,
|
|
FilterOption,
|
|
} from './types';
|
|
import {
|
|
APPROVAL_TAB_LABELS,
|
|
SORT_OPTIONS,
|
|
FILTER_OPTIONS,
|
|
APPROVAL_TYPE_LABELS,
|
|
APPROVAL_STATUS_LABELS,
|
|
APPROVAL_STATUS_COLORS,
|
|
} from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import { usePermission } from '@/hooks/usePermission';
|
|
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
|
|
|
|
// ===== 통계 타입 =====
|
|
interface InboxSummary {
|
|
total: number;
|
|
pending: number;
|
|
approved: number;
|
|
rejected: number;
|
|
}
|
|
|
|
export function ApprovalBox() {
|
|
const router = useRouter();
|
|
const [isPending, startTransition] = useTransition();
|
|
const { canApprove } = usePermission();
|
|
|
|
// ===== 상태 관리 =====
|
|
const [activeTab, setActiveTab] = useState<ApprovalTabType>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
|
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const itemsPerPage = 20;
|
|
|
|
// 날짜 범위 상태
|
|
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
|
|
|
// 다이얼로그 상태
|
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
|
const [rejectComment, setRejectComment] = useState('');
|
|
const [pendingSelectedItems, setPendingSelectedItems] = useState<Set<string>>(new Set());
|
|
const [pendingClearSelection, setPendingClearSelection] = useState<(() => void) | null>(null);
|
|
|
|
// ===== 문서 상세 모달 상태 =====
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
|
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
|
|
const [isModalLoading, setIsModalLoading] = useState(false);
|
|
|
|
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
|
|
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
|
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
|
|
|
|
// API 데이터
|
|
const [data, setData] = useState<ApprovalRecord[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const isInitialLoadDone = useRef(false);
|
|
|
|
// 통계 데이터
|
|
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
|
|
|
|
// ===== 데이터 로드 =====
|
|
const loadData = useCallback(async () => {
|
|
if (!isInitialLoadDone.current) {
|
|
setIsLoading(true);
|
|
}
|
|
try {
|
|
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
|
|
switch (sortOption) {
|
|
case 'latest':
|
|
return { sort_by: 'created_at', sort_dir: 'desc' };
|
|
case 'oldest':
|
|
return { sort_by: 'created_at', sort_dir: 'asc' };
|
|
case 'draftDateAsc':
|
|
return { sort_by: 'created_at', sort_dir: 'asc' };
|
|
case 'draftDateDesc':
|
|
return { sort_by: 'created_at', sort_dir: 'desc' };
|
|
default:
|
|
return { sort_by: 'created_at', sort_dir: 'desc' };
|
|
}
|
|
})();
|
|
|
|
const result = await getInbox({
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
search: searchQuery || undefined,
|
|
status: activeTab !== 'all' ? activeTab : undefined,
|
|
approval_type: filterOption !== 'all' ? filterOption : undefined,
|
|
start_date: startDate || undefined,
|
|
end_date: endDate || undefined,
|
|
...sortConfig,
|
|
});
|
|
|
|
setData(result.data);
|
|
setTotalCount(result.total);
|
|
setTotalPages(result.lastPage);
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Failed to load inbox:', error);
|
|
toast.error('결재함 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
isInitialLoadDone.current = true;
|
|
}
|
|
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
|
|
|
|
// ===== 초기 로드 =====
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchQuery, filterOption, sortOption, activeTab]);
|
|
|
|
// ===== 탭 변경 핸들러 =====
|
|
const handleTabChange = useCallback((value: string) => {
|
|
setActiveTab(value as ApprovalTabType);
|
|
setSearchQuery('');
|
|
}, []);
|
|
|
|
// ===== 전체 탭일 때만 통계 업데이트 =====
|
|
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 rejected = data.filter((item) => item.status === 'rejected').length;
|
|
|
|
setFixedStats({
|
|
all: totalCount,
|
|
pending,
|
|
approved,
|
|
rejected,
|
|
});
|
|
}
|
|
}, [data, totalCount, activeTab]);
|
|
|
|
// ===== 승인/반려 핸들러 =====
|
|
const handleApproveClick = useCallback(
|
|
(selectedItems: Set<string>, onClearSelection: () => void) => {
|
|
if (selectedItems.size === 0) return;
|
|
setPendingSelectedItems(selectedItems);
|
|
setPendingClearSelection(() => onClearSelection);
|
|
setApproveDialogOpen(true);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleApproveConfirm = useCallback(async () => {
|
|
const ids = Array.from(pendingSelectedItems);
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await approveDocumentsBulk(ids);
|
|
if (result.success) {
|
|
toast.success('승인 완료', {
|
|
description: '결재 승인이 완료되었습니다.',
|
|
});
|
|
pendingClearSelection?.();
|
|
loadData();
|
|
} else {
|
|
toast.error(result.error || '승인 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Approve error:', error);
|
|
toast.error('승인 처리 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
|
|
setApproveDialogOpen(false);
|
|
setPendingSelectedItems(new Set());
|
|
setPendingClearSelection(null);
|
|
}, [pendingSelectedItems, pendingClearSelection, loadData]);
|
|
|
|
const handleRejectClick = useCallback(
|
|
(selectedItems: Set<string>, onClearSelection: () => void) => {
|
|
if (selectedItems.size === 0) return;
|
|
setPendingSelectedItems(selectedItems);
|
|
setPendingClearSelection(() => onClearSelection);
|
|
setRejectComment('');
|
|
setRejectDialogOpen(true);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleRejectConfirm = useCallback(async () => {
|
|
if (!rejectComment.trim()) {
|
|
toast.error('반려 사유를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
const ids = Array.from(pendingSelectedItems);
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await rejectDocumentsBulk(ids, rejectComment);
|
|
if (result.success) {
|
|
toast.success('반려 완료', {
|
|
description: '결재 반려가 완료되었습니다.',
|
|
});
|
|
pendingClearSelection?.();
|
|
setRejectComment('');
|
|
loadData();
|
|
} else {
|
|
toast.error(result.error || '반려 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Reject error:', error);
|
|
toast.error('반려 처리 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
|
|
setRejectDialogOpen(false);
|
|
setPendingSelectedItems(new Set());
|
|
setPendingClearSelection(null);
|
|
}, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]);
|
|
|
|
// ===== 문서 클릭 핸들러 =====
|
|
const handleDocumentClick = useCallback(async (item: ApprovalRecord) => {
|
|
setSelectedDocument(item);
|
|
setIsModalLoading(true);
|
|
setIsModalOpen(true);
|
|
|
|
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));
|
|
if (result.success && result.data) {
|
|
const formData = result.data;
|
|
const docType = getDocumentType(item.approvalType);
|
|
|
|
// 기안자 정보
|
|
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:
|
|
item.status === 'approved'
|
|
? ('approved' as const)
|
|
: item.status === 'rejected'
|
|
? ('rejected' as const)
|
|
: index === 0
|
|
? ('pending' as const)
|
|
: ('none' as const),
|
|
}));
|
|
|
|
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
|
|
|
switch (docType) {
|
|
case 'expenseEstimate':
|
|
convertedData = {
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
items: formData.expenseEstimateData?.items.map(item => ({
|
|
id: item.id,
|
|
expectedPaymentDate: item.expectedPaymentDate,
|
|
category: item.category,
|
|
amount: item.amount,
|
|
vendor: item.vendor,
|
|
account: item.memo || '',
|
|
})) || [],
|
|
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
|
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
|
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
|
approvers,
|
|
drafter,
|
|
};
|
|
break;
|
|
case 'expenseReport':
|
|
convertedData = {
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
requestDate: formData.expenseReportData?.requestDate || '',
|
|
paymentDate: formData.expenseReportData?.paymentDate || '',
|
|
items: formData.expenseReportData?.items.map((item, index) => ({
|
|
id: item.id,
|
|
no: index + 1,
|
|
description: item.description,
|
|
amount: item.amount,
|
|
note: item.note,
|
|
})) || [],
|
|
cardInfo: formData.expenseReportData?.cardId || '-',
|
|
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
|
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
|
|
approvers,
|
|
drafter,
|
|
};
|
|
break;
|
|
default:
|
|
// 품의서
|
|
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
|
`/api/proxy/files/${f.id}/download`
|
|
);
|
|
convertedData = {
|
|
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,
|
|
};
|
|
break;
|
|
}
|
|
|
|
setModalData(convertedData);
|
|
} 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);
|
|
}
|
|
}, []);
|
|
|
|
const handleModalEdit = useCallback(() => {
|
|
if (selectedDocument) {
|
|
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
|
setIsModalOpen(false);
|
|
}
|
|
}, [selectedDocument, router]);
|
|
|
|
const handleModalCopy = useCallback(() => {
|
|
toast.info('문서 복제 기능은 준비 중입니다.');
|
|
setIsModalOpen(false);
|
|
}, []);
|
|
|
|
const handleModalApprove = useCallback(async () => {
|
|
if (!selectedDocument?.id) return;
|
|
const result = await approveDocument(selectedDocument.id);
|
|
if (result.success) {
|
|
toast.success('문서가 승인되었습니다.');
|
|
loadData();
|
|
} else {
|
|
toast.error(result.error || '승인에 실패했습니다.');
|
|
}
|
|
setIsModalOpen(false);
|
|
}, [selectedDocument, loadData]);
|
|
|
|
const handleModalReject = useCallback(async () => {
|
|
if (!selectedDocument?.id) return;
|
|
const result = await rejectDocument(selectedDocument.id, '반려');
|
|
if (result.success) {
|
|
toast.success('문서가 반려되었습니다.');
|
|
loadData();
|
|
} else {
|
|
toast.error(result.error || '반려에 실패했습니다.');
|
|
}
|
|
setIsModalOpen(false);
|
|
}, [selectedDocument, loadData]);
|
|
|
|
// ===== 문서 타입 변환 =====
|
|
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
|
switch (approvalType) {
|
|
case 'expense_estimate':
|
|
return 'expenseEstimate';
|
|
case 'expense_report':
|
|
return 'expenseReport';
|
|
case 'document':
|
|
return 'document';
|
|
default:
|
|
return 'proposal';
|
|
}
|
|
};
|
|
// ===== 탭 옵션 =====
|
|
const tabs: TabOption[] = useMemo(
|
|
() => [
|
|
{
|
|
value: 'all',
|
|
label: APPROVAL_TAB_LABELS.all,
|
|
count: fixedStats.all,
|
|
color: 'blue',
|
|
},
|
|
{
|
|
value: 'pending',
|
|
label: APPROVAL_TAB_LABELS.pending,
|
|
count: fixedStats.pending,
|
|
color: 'yellow',
|
|
},
|
|
{
|
|
value: 'approved',
|
|
label: APPROVAL_TAB_LABELS.approved,
|
|
count: fixedStats.approved,
|
|
color: 'green',
|
|
},
|
|
{
|
|
value: 'rejected',
|
|
label: APPROVAL_TAB_LABELS.rejected,
|
|
count: fixedStats.rejected,
|
|
color: 'red',
|
|
},
|
|
],
|
|
[fixedStats]
|
|
);
|
|
|
|
// ===== UniversalListPage 설정 =====
|
|
const approvalBoxConfig: UniversalListConfig<ApprovalRecord> = useMemo(
|
|
() => ({
|
|
title: '결재함',
|
|
description: '결재 문서를 관리합니다',
|
|
icon: FileCheck,
|
|
basePath: '/approval/inbox',
|
|
|
|
idField: 'id',
|
|
|
|
actions: {
|
|
getList: async () => ({
|
|
success: true,
|
|
data: data,
|
|
totalCount: totalCount,
|
|
totalPages: totalPages,
|
|
}),
|
|
},
|
|
|
|
columns: [
|
|
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
|
{ key: 'documentNo', label: '문서번호' },
|
|
{ key: 'approvalType', label: '문서유형' },
|
|
{ key: 'title', label: '제목' },
|
|
{ key: 'drafter', label: '기안자' },
|
|
{ key: 'approver', label: '결재자' },
|
|
{ key: 'draftDate', label: '기안일시' },
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
|
],
|
|
|
|
tabs: tabs,
|
|
defaultTab: activeTab,
|
|
|
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
|
hideSearch: true,
|
|
searchValue: searchQuery,
|
|
onSearchChange: setSearchQuery,
|
|
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: true,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
|
searchFilter: (item: ApprovalRecord, search: string) => {
|
|
const s = search.toLowerCase();
|
|
return (
|
|
item.title?.toLowerCase().includes(s) ||
|
|
item.drafter?.toLowerCase().includes(s) ||
|
|
item.drafterDepartment?.toLowerCase().includes(s) ||
|
|
false
|
|
);
|
|
},
|
|
|
|
itemsPerPage: itemsPerPage,
|
|
|
|
// 모바일 필터 설정
|
|
filterConfig: [
|
|
{
|
|
key: 'approvalType',
|
|
label: '문서유형',
|
|
type: 'single',
|
|
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
|
},
|
|
{
|
|
key: 'sort',
|
|
label: '정렬',
|
|
type: 'single',
|
|
options: SORT_OPTIONS,
|
|
},
|
|
],
|
|
initialFilters: {
|
|
approvalType: filterOption,
|
|
sort: sortOption,
|
|
},
|
|
filterTitle: '결재함 필터',
|
|
|
|
computeStats: () => [
|
|
{
|
|
label: '전체결재',
|
|
value: `${fixedStats.all}건`,
|
|
icon: Files,
|
|
iconColor: 'text-blue-500',
|
|
},
|
|
{
|
|
label: '미결재',
|
|
value: `${fixedStats.pending}건`,
|
|
icon: Clock,
|
|
iconColor: 'text-yellow-500',
|
|
},
|
|
{
|
|
label: '결재완료',
|
|
value: `${fixedStats.approved}건`,
|
|
icon: FileCheck,
|
|
iconColor: 'text-green-500',
|
|
},
|
|
{
|
|
label: '결재반려',
|
|
value: `${fixedStats.rejected}건`,
|
|
icon: FileX,
|
|
iconColor: 'text-red-500',
|
|
},
|
|
],
|
|
|
|
selectionActions: ({ selectedItems, onClearSelection }) => canApprove ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => handleApproveClick(selectedItems, onClearSelection)}
|
|
>
|
|
<Check className="h-4 w-4 mr-2" />
|
|
승인
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => handleRejectClick(selectedItems, onClearSelection)}
|
|
>
|
|
<X className="h-4 w-4 mr-2" />
|
|
반려
|
|
</Button>
|
|
</>
|
|
) : 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;
|
|
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
className="hover:bg-muted/50 cursor-pointer"
|
|
onClick={() => handleDocumentClick(item)}
|
|
>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
|
</TableCell>
|
|
<TableCell className="text-center">{globalIndex}</TableCell>
|
|
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-medium max-w-[200px] truncate">
|
|
{item.title}
|
|
</TableCell>
|
|
<TableCell>{item.drafter}</TableCell>
|
|
<TableCell>{item.approver || '-'}</TableCell>
|
|
<TableCell>{item.draftDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
|
{APPROVAL_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
renderMobileCard: (item, index, globalIndex, handlers) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
|
|
return (
|
|
<ListMobileCard
|
|
id={item.id}
|
|
title={item.title}
|
|
headerBadges={
|
|
<div className="flex gap-1">
|
|
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
|
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
|
{APPROVAL_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</div>
|
|
}
|
|
isSelected={isSelected}
|
|
onToggleSelection={onToggle}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<InfoField label="문서번호" value={item.documentNo} />
|
|
<InfoField label="기안자" value={item.drafter} />
|
|
<InfoField label="부서" value={item.drafterDepartment} />
|
|
<InfoField label="직급" value={item.drafterPosition} />
|
|
<InfoField label="기안일" value={item.draftDate} />
|
|
<InfoField label="결재일" value={item.approvalDate || '-'} />
|
|
</div>
|
|
}
|
|
actions={
|
|
item.status === 'pending' && isSelected && canApprove ? (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="default"
|
|
className="flex-1"
|
|
onClick={() =>
|
|
handleApproveClick(new Set([item.id]), () => {})
|
|
}
|
|
>
|
|
<Check className="w-4 h-4 mr-2" /> 승인
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() =>
|
|
handleRejectClick(new Set([item.id]), () => {})
|
|
}
|
|
>
|
|
<X className="w-4 h-4 mr-2" /> 반려
|
|
</Button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
onClick={() => handleDocumentClick(item)}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderDialogs: () => (
|
|
<>
|
|
{/* 승인 확인 다이얼로그 */}
|
|
<ConfirmDialog
|
|
open={approveDialogOpen}
|
|
onOpenChange={setApproveDialogOpen}
|
|
onConfirm={handleApproveConfirm}
|
|
title="결재 승인"
|
|
description={`정말 ${pendingSelectedItems.size}건을 승인하시겠습니까?`}
|
|
variant="success"
|
|
confirmText="승인"
|
|
/>
|
|
|
|
{/* 반려 확인 다이얼로그 */}
|
|
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>결재 반려</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{pendingSelectedItems.size}건의 결재를 반려합니다. 반려 사유를
|
|
입력해주세요.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="py-4">
|
|
<Label htmlFor="reject-comment" className="text-sm font-medium">
|
|
반려 사유 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="reject-comment"
|
|
placeholder="반려 사유를 입력해주세요..."
|
|
value={rejectComment}
|
|
onChange={(e) => setRejectComment(e.target.value)}
|
|
className="mt-2 min-h-[100px]"
|
|
/>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setRejectComment('')}>
|
|
취소
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleRejectConfirm}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
disabled={!rejectComment.trim()}
|
|
>
|
|
반려
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 문서 상세 모달 */}
|
|
{selectedDocument && modalData && (
|
|
<DocumentDetailModal
|
|
open={isModalOpen}
|
|
onOpenChange={(open) => {
|
|
setIsModalOpen(open);
|
|
if (!open) {
|
|
setModalData(null);
|
|
}
|
|
}}
|
|
documentType={getDocumentType(selectedDocument.approvalType)}
|
|
data={modalData}
|
|
mode="inbox"
|
|
onEdit={handleModalEdit}
|
|
onCopy={handleModalCopy}
|
|
onApprove={canApprove ? handleModalApprove : undefined}
|
|
onReject={canApprove ? handleModalReject : undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* 검사성적서 모달 (work_order 연결 문서) */}
|
|
<InspectionReportModal
|
|
open={isInspectionModalOpen}
|
|
onOpenChange={(open) => {
|
|
setIsInspectionModalOpen(open);
|
|
if (!open) {
|
|
setInspectionWorkOrderId(null);
|
|
}
|
|
}}
|
|
workOrderId={inspectionWorkOrderId}
|
|
readOnly={true}
|
|
/>
|
|
</>
|
|
),
|
|
}),
|
|
[
|
|
data,
|
|
totalCount,
|
|
totalPages,
|
|
tabs,
|
|
activeTab,
|
|
startDate,
|
|
endDate,
|
|
fixedStats,
|
|
handleApproveClick,
|
|
handleRejectClick,
|
|
filterOption,
|
|
sortOption,
|
|
handleDocumentClick,
|
|
approveDialogOpen,
|
|
pendingSelectedItems,
|
|
handleApproveConfirm,
|
|
rejectDialogOpen,
|
|
rejectComment,
|
|
handleRejectConfirm,
|
|
selectedDocument,
|
|
isModalOpen,
|
|
modalData,
|
|
handleModalEdit,
|
|
handleModalCopy,
|
|
handleModalApprove,
|
|
handleModalReject,
|
|
canApprove,
|
|
isInspectionModalOpen,
|
|
inspectionWorkOrderId,
|
|
]
|
|
);
|
|
|
|
// 모바일 필터 변경 핸들러
|
|
const handleMobileFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
|
if (filters.approvalType) {
|
|
setFilterOption(filters.approvalType as FilterOption);
|
|
}
|
|
if (filters.sort) {
|
|
setSortOption(filters.sort as SortOption);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<UniversalListPage<ApprovalRecord>
|
|
config={approvalBoxConfig}
|
|
initialData={data}
|
|
initialTotalCount={totalCount}
|
|
externalPagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
onTabChange={handleTabChange}
|
|
onSearchChange={setSearchQuery}
|
|
onFilterChange={handleMobileFilterChange}
|
|
externalIsLoading={isLoading}
|
|
/>
|
|
);
|
|
}
|