chore: 백업/레거시 파일 정리 (-9,927줄)
- approval_backup_v1/ 전체 삭제 (27파일) - SalaryManagement_backup_20260312/ 삭제 (5파일) - AccountManagement/_legacy/ 삭제 - vehicle/types.ts 삭제
This commit is contained in:
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* 결재함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/inbox - 결재함 목록 조회
|
||||
* - GET /api/v1/approvals/inbox/summary - 결재함 통계
|
||||
* - POST /api/v1/approvals/{id}/approve - 승인 처리
|
||||
* - POST /api/v1/approvals/{id}/reject - 반려 처리
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface InboxSummary {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
interface InboxApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: InboxStepApiData[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface InboxStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
function mapApiStatus(apiStatus: string): ApprovalStatus {
|
||||
const statusMap: Record<string, ApprovalStatus> = {
|
||||
'pending': 'pending', 'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
|
||||
function mapTabToApiStatus(tabStatus: string): string | undefined {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': 'requested', 'approved': 'completed', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[tabStatus];
|
||||
}
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
function mapDocumentStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': '진행중', 'approved': '완료', 'rejected': '반려',
|
||||
};
|
||||
return statusMap[status] || '진행중';
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
|
||||
const currentStep = data.steps?.find(s => s.step_type === 'approval' || s.step_type === 'agreement');
|
||||
const approver = currentStep?.approver;
|
||||
const stepStatus = currentStep?.status || 'pending';
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
documentStatus: mapDocumentStatus(data.status),
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterDepartment: data.drafter?.department?.name || '',
|
||||
drafterPosition: data.drafter?.position || '',
|
||||
approvalDate: currentStep?.processed_at?.replace('T', ' ').substring(0, 16),
|
||||
approver: approver?.name,
|
||||
status: mapApiStatus(stepStatus),
|
||||
priority: 'normal',
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getInbox(params?: {
|
||||
page?: number; per_page?: number; search?: string; status?: string;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
start_date?: string; end_date?: string;
|
||||
}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/inbox', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? mapTabToApiStatus(params.status) : undefined,
|
||||
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInboxSummary(): Promise<InboxSummary | null> {
|
||||
const result = await executeServerAction<InboxSummary>({
|
||||
url: buildApiUrl('/api/v1/approvals/inbox/summary'),
|
||||
errorMessage: '결재함 통계 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function approveDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/approve`),
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '승인 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectDocument(id: string, comment: string): Promise<ActionResult> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/reject`),
|
||||
method: 'POST',
|
||||
body: { comment },
|
||||
errorMessage: '반려 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveDocumentsBulk(ids: string[], comment?: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await approveDocument(id, comment);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 승인 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 연결 문서(Document) 조회
|
||||
// ============================================
|
||||
|
||||
interface LinkedDocumentApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
drafter?: {
|
||||
id: number; name: string; position?: string;
|
||||
department?: { name: string };
|
||||
tenant_profile?: { position_key?: string; department?: { name: string } };
|
||||
};
|
||||
steps?: InboxStepApiData[];
|
||||
linkable?: {
|
||||
id: number;
|
||||
title: string;
|
||||
document_no: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
linkable_type?: string;
|
||||
linkable_id?: number;
|
||||
template?: { id: number; name: string; code: string };
|
||||
data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>;
|
||||
approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>;
|
||||
attachments?: Array<{ id: number; display_name: string; file_path: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkedDocumentResult {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>;
|
||||
approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>;
|
||||
drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' };
|
||||
attachments?: Array<{ id: number; name: string; url: string }>;
|
||||
}
|
||||
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장',
|
||||
'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
export async function getDocumentApprovalById(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: LinkedDocumentResult;
|
||||
error?: string;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const apiData = result.data as LinkedDocumentApiData;
|
||||
const linkable = apiData.linkable;
|
||||
|
||||
const drafter = {
|
||||
id: String(apiData.drafter?.id || ''),
|
||||
name: apiData.drafter?.name || '',
|
||||
position: apiData.drafter?.tenant_profile?.position_key
|
||||
? getPositionLabel(apiData.drafter.tenant_profile.position_key)
|
||||
: (apiData.drafter?.position || ''),
|
||||
department: apiData.drafter?.tenant_profile?.department?.name
|
||||
|| apiData.drafter?.department?.name || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
|
||||
const approvers = (apiData.steps || [])
|
||||
.filter(s => s.step_type === 'approval' || s.step_type === 'agreement')
|
||||
.map(step => ({
|
||||
id: String(step.approver?.id || step.approver_id),
|
||||
name: step.approver?.name || '',
|
||||
position: step.approver?.position || '',
|
||||
department: step.approver?.department?.name || '',
|
||||
status: (step.status === 'approved' ? 'approved'
|
||||
: step.status === 'rejected' ? 'rejected'
|
||||
: step.status === 'pending' ? 'pending'
|
||||
: 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||
}));
|
||||
|
||||
// work_order 연결 문서인 경우 workOrderId 추출
|
||||
const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
documentNo: linkable?.document_no || apiData.document_number,
|
||||
createdAt: linkable?.created_at || '',
|
||||
title: linkable?.title || apiData.title,
|
||||
templateName: linkable?.template?.name || '',
|
||||
templateCode: linkable?.template?.code || '',
|
||||
status: linkable?.status || apiData.status,
|
||||
workOrderId,
|
||||
documentData: (linkable?.data || []).map(d => ({
|
||||
fieldKey: d.field_key,
|
||||
fieldLabel: d.field_label || d.field_key,
|
||||
value: d.field_value ?? d.value,
|
||||
})),
|
||||
approvers,
|
||||
drafter,
|
||||
attachments: (linkable?.attachments || []).map(a => ({
|
||||
id: a.id,
|
||||
name: a.display_name,
|
||||
url: `/api/proxy/files/${a.id}/download`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await rejectDocument(id, comment);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 반려 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,900 +0,0 @@
|
||||
'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,
|
||||
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';
|
||||
|
||||
export function ApprovalBox() {
|
||||
const router = useRouter();
|
||||
const [, 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 [, 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: '문서번호', copyable: true },
|
||||
{ key: 'approvalType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'drafter', label: '기안자', copyable: true },
|
||||
{ key: 'approver', label: '결재자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 결재함 타입 정의
|
||||
* 4개 메인 탭: 전체결재, 미결재, 결재완료, 결재반려
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 결재 상태
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
{ value: 'document', label: '문서 결재' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'draftDateAsc' | 'draftDateDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'draftDateAsc', label: '기안일 오름차순' },
|
||||
{ value: 'draftDateDesc', label: '기안일 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 결재 문서 레코드 =====
|
||||
export interface ApprovalRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 결재유형 (휴가, 경비 등)
|
||||
documentStatus: string; // 문서상태
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일
|
||||
drafter: string; // 기안자
|
||||
drafterDepartment: string; // 기안자 부서
|
||||
drafterPosition: string; // 기안자 직급
|
||||
approvalDate?: string; // 결재일
|
||||
approver?: string; // 결재자
|
||||
status: ApprovalStatus; // 결재 상태
|
||||
priority?: 'high' | 'normal' | 'low'; // 우선순위
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
export interface ApprovalFormData {
|
||||
documentId: string;
|
||||
action: 'approve' | 'reject';
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const APPROVAL_TAB_LABELS: Record<ApprovalTabType, string> = {
|
||||
all: '전체결재',
|
||||
pending: '미결재',
|
||||
approved: '결재완료',
|
||||
rejected: '결재반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
document: '문서 결재',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||
expense_report: 'blue',
|
||||
proposal: 'green',
|
||||
expense_estimate: 'purple',
|
||||
document: 'orange',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
pending: '대기',
|
||||
approved: '승인',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_COLORS: Record<ApprovalStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ApprovalPerson } from './types';
|
||||
import { getEmployees } from './actions';
|
||||
|
||||
interface ApprovalLineSectionProps {
|
||||
data: ApprovalPerson[];
|
||||
onChange: (data: ApprovalPerson[]) => void;
|
||||
}
|
||||
|
||||
export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) {
|
||||
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
|
||||
|
||||
// 직원 목록 로드
|
||||
useEffect(() => {
|
||||
getEmployees().then(setEmployees);
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: `temp-${Date.now()}`,
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
onChange([...data, newPerson]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, employeeId: string) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
if (employee) {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...employee };
|
||||
onChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">결재선</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
결재선을 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { BasicInfo, DocumentType } from './types';
|
||||
import { DOCUMENT_TYPE_OPTIONS } from './types';
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
data: BasicInfo;
|
||||
onChange: (data: BasicInfo) => void;
|
||||
}
|
||||
|
||||
export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 기안자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="drafter">기안자</Label>
|
||||
<Input
|
||||
id="drafter"
|
||||
value={data.drafter}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작성일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftDate">작성일</Label>
|
||||
<Input
|
||||
id="draftDate"
|
||||
value={data.draftDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문서번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentNo">문서번호</Label>
|
||||
<Input
|
||||
id="documentNo"
|
||||
placeholder="문서번호를 입력해주세요"
|
||||
value={data.documentNo}
|
||||
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문서유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentType">문서유형</Label>
|
||||
<Select
|
||||
value={data.documentType}
|
||||
onValueChange={(value) => onChange({ ...data, documentType: value as DocumentType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="문서유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseEstimateFormProps {
|
||||
data: ExpenseEstimateData;
|
||||
onChange: (data: ExpenseEstimateData) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseEstimateForm({ data, onChange, isLoading }: ExpenseEstimateFormProps) {
|
||||
const items = data.items;
|
||||
|
||||
const handleCheckChange = (id: string, checked: boolean) => {
|
||||
const newItems = items.map((item) =>
|
||||
item.id === id ? { ...item, checked } : item
|
||||
);
|
||||
const totalExpense = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({
|
||||
...data,
|
||||
items: newItems,
|
||||
totalExpense,
|
||||
finalDifference: data.accountBalance - totalExpense,
|
||||
});
|
||||
};
|
||||
|
||||
// 월별 그룹핑
|
||||
const groupedByMonth = items.reduce((acc, item) => {
|
||||
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, ExpenseEstimateItem[]>);
|
||||
|
||||
const getMonthSubtotal = (monthItems: ExpenseEstimateItem[]) => {
|
||||
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const totalExpense = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액
|
||||
const finalDifference = accountBalance - totalExpense;
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<ContentSkeleton type="table" rows={5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>등록된 지출 예상 항목이 없습니다.</p>
|
||||
<p className="text-sm mt-1">지출 예상 항목을 먼저 등록해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 지출 예상 내역서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center"></TableHead>
|
||||
<TableHead className="min-w-[120px]">예상 지급일</TableHead>
|
||||
<TableHead className="min-w-[150px]">항목</TableHead>
|
||||
<TableHead className="min-w-[120px] text-right">지출금액</TableHead>
|
||||
<TableHead className="min-w-[100px]">거래처</TableHead>
|
||||
<TableHead className="min-w-[150px]">적록</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||
<Fragment key={month}>
|
||||
{monthItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => handleCheckChange(item.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.expectedPaymentDate}</TableCell>
|
||||
<TableCell>{item.category}</TableCell>
|
||||
<TableCell className="text-right text-blue-600 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell>{item.vendor}</TableCell>
|
||||
<TableCell>{item.memo}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 월별 소계 */}
|
||||
<TableRow className="bg-pink-50">
|
||||
<TableCell colSpan={2} className="font-medium">
|
||||
{month.replace('-', '년 ')}월 계
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 합계 행들 */}
|
||||
<TableRow className="bg-gray-50 border-t-2">
|
||||
<TableCell colSpan={3} className="font-semibold">지출 합계</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(totalExpense)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">계좌 잔액</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{formatCurrency(accountBalance)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">최종 차액</TableCell>
|
||||
<TableCell className={`text-right font-bold ${finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(finalDifference)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExpenseReportData, ExpenseReportItem } from './types';
|
||||
import { CARD_OPTIONS } from './types';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseReportFormProps {
|
||||
data: ExpenseReportData;
|
||||
onChange: (data: ExpenseReportData) => void;
|
||||
}
|
||||
|
||||
export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
const handleAddItem = () => {
|
||||
const newItem: ExpenseReportItem = {
|
||||
id: `item-${Date.now()}`,
|
||||
description: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
onChange({ ...data, items: [...data.items, newItem] });
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = data.items.filter((_, i) => i !== index);
|
||||
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({ ...data, items: newItems, totalAmount });
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, field: keyof ExpenseReportItem, value: string | number) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({ ...data, items: newItems, totalAmount });
|
||||
};
|
||||
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
const handleRemoveUploadedFile = (fileId: number) => {
|
||||
const updatedFiles = (data.uploadedFiles || []).filter((f) => f.id !== fileId);
|
||||
onChange({ ...data, uploadedFiles: updatedFiles });
|
||||
};
|
||||
|
||||
// 새 첨부 파일 삭제
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
const updatedAttachments = data.attachments.filter((_, i) => i !== index);
|
||||
onChange({ ...data, attachments: updatedAttachments });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 지출 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requestDate">지출 요청일</Label>
|
||||
<DatePicker
|
||||
value={data.requestDate}
|
||||
onChange={(date) => onChange({ ...data, requestDate: date })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentDate">결제일</Label>
|
||||
<DatePicker
|
||||
value={data.paymentDate}
|
||||
onChange={(date) => onChange({ ...data, paymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지출결의서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">지출결의서 정보</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="min-w-[200px]">적요</TableHead>
|
||||
<TableHead className="min-w-[150px]">금액</TableHead>
|
||||
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||
<TableHead className="w-[60px] text-center">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-gray-400">
|
||||
항목을 추가해주세요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
placeholder="적요를 입력해주세요"
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange(index, 'description', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
placeholder="금액을 입력해주세요"
|
||||
value={item.amount || 0}
|
||||
onChange={(value) => handleItemChange(index, 'amount', value ?? 0)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
placeholder="비고를 입력해주세요"
|
||||
value={item.note}
|
||||
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결제 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">결제 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card">카드</Label>
|
||||
<Select
|
||||
value={data.cardId}
|
||||
onValueChange={(value) => onChange({ ...data, cardId: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카드를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>총 비용</Label>
|
||||
<div className="h-10 px-3 py-2 bg-gray-50 border rounded-md text-right font-semibold">
|
||||
{formatCurrency(data.totalAmount)}원
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
import type { ProposalData } from './types';
|
||||
|
||||
// 거래처 옵션 타입
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProposalFormProps {
|
||||
data: ProposalData;
|
||||
onChange: (data: ProposalData) => void;
|
||||
}
|
||||
|
||||
export function ProposalForm({ data, onChange }: ProposalFormProps) {
|
||||
// 거래처 목록 상태
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
const [isLoadingClients, setIsLoadingClients] = useState(true);
|
||||
|
||||
// 거래처 목록 로드 (매입 거래처만)
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
setIsLoadingClients(true);
|
||||
const result = await getClients({ size: 1000, only_active: true });
|
||||
if (result.success) {
|
||||
// 매입 거래처(purchase, both)만 필터링
|
||||
const purchaseClients = result.data
|
||||
.filter((v) => v.category === 'purchase' || v.category === 'both')
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.vendorName,
|
||||
}));
|
||||
setClients(purchaseClients);
|
||||
}
|
||||
setIsLoadingClients(false);
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// 거래처 선택 핸들러
|
||||
const handleVendorChange = (vendorId: string) => {
|
||||
const selected = clients.find((c) => c.id === vendorId);
|
||||
onChange({
|
||||
...data,
|
||||
vendorId,
|
||||
vendor: selected?.name || '',
|
||||
});
|
||||
};
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
const handleRemoveUploadedFile = (fileId: number) => {
|
||||
const updatedFiles = (data.uploadedFiles || []).filter((f) => f.id !== fileId);
|
||||
onChange({ ...data, uploadedFiles: updatedFiles });
|
||||
};
|
||||
|
||||
// 새 첨부 파일 삭제
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
const updatedAttachments = data.attachments.filter((_, i) => i !== index);
|
||||
onChange({ ...data, attachments: updatedAttachments });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 구매처 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">구매처 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor">구매처</Label>
|
||||
<Select
|
||||
value={data.vendorId || ''}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isLoadingClients}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingClients ? '불러오는 중...' : '구매처를 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorPaymentDate">구매처 결제일</Label>
|
||||
<DatePicker
|
||||
value={data.vendorPaymentDate}
|
||||
onChange={(date) => onChange({ ...data, vendorPaymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">품의서 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="제목을 입력해주세요"
|
||||
value={data.title}
|
||||
onChange={(e) => onChange({ ...data, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품의 내역 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">품의 내역</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="품의 내역을 입력해주세요"
|
||||
value={data.description}
|
||||
onChange={(e) => onChange({ ...data, description: e.target.value })}
|
||||
className="min-h-[100px] pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="녹음"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
녹음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 사유 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">품의 사유</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="reason"
|
||||
placeholder="품의 사유를 입력해주세요"
|
||||
value={data.reason}
|
||||
onChange={(e) => onChange({ ...data, reason: e.target.value })}
|
||||
className="min-h-[100px] pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="녹음"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
녹음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예상 비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimatedCost">예상 비용</Label>
|
||||
<CurrencyInput
|
||||
id="estimatedCost"
|
||||
placeholder="금액을 입력해주세요"
|
||||
value={data.estimatedCost || 0}
|
||||
onChange={(value) => onChange({ ...data, estimatedCost: value ?? 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ApprovalPerson } from './types';
|
||||
import { getEmployees } from './actions';
|
||||
|
||||
interface ReferenceSectionProps {
|
||||
data: ApprovalPerson[];
|
||||
onChange: (data: ApprovalPerson[]) => void;
|
||||
}
|
||||
|
||||
export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
|
||||
|
||||
// 직원 목록 로드
|
||||
useEffect(() => {
|
||||
getEmployees().then(setEmployees);
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: `temp-${Date.now()}`,
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
onChange([...data, newPerson]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, employeeId: string) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
if (employee) {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...employee };
|
||||
onChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">참조</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
참조자를 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
/**
|
||||
* 문서 작성 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/reports/expense-estimate - 비용견적서 항목 조회
|
||||
* - GET /api/v1/employees - 직원 목록 (결재선/참조 선택용)
|
||||
* - POST /api/v1/approvals - 결재 문서 생성 (임시저장)
|
||||
* - POST /api/v1/approvals/{id}/submit - 결재 문서 상신
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
ExpenseEstimateItem,
|
||||
ApprovalPerson,
|
||||
DocumentFormData,
|
||||
UploadedFile,
|
||||
} from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
// 비용견적서 API 응답 타입
|
||||
interface ExpenseEstimateApiItem {
|
||||
id: number;
|
||||
expected_payment_date: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
account_info?: string;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
interface ExpenseEstimateApiResponse {
|
||||
year_month: string;
|
||||
items: ExpenseEstimateApiItem[];
|
||||
total_expense: number;
|
||||
account_balance: number;
|
||||
final_difference: number;
|
||||
}
|
||||
|
||||
// 직원 API 응답 타입 (TenantUserProfile 구조)
|
||||
interface EmployeeApiData {
|
||||
id: number; // TenantUserProfile.id
|
||||
user_id: number; // User.id (결재선에 사용)
|
||||
position_key?: string; // 직책 코드 (EXECUTIVE, DIRECTOR 등)
|
||||
user?: {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 결재 문서 생성 응답 타입
|
||||
interface ApprovalCreateResponse {
|
||||
id: number;
|
||||
document_number: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 비용견적서 API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
function transformExpenseEstimateItem(item: ExpenseEstimateApiItem): ExpenseEstimateItem {
|
||||
return {
|
||||
id: String(item.id),
|
||||
checked: false,
|
||||
expectedPaymentDate: item.expected_payment_date,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
memo: item.account_info || item.memo || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드를 한글 라벨로 변환 (직원 목록용)
|
||||
*/
|
||||
function getPositionLabelForEmployee(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 API 데이터 → 결재자 데이터 변환
|
||||
* API는 TenantUserProfile 구조를 반환함
|
||||
*/
|
||||
function transformEmployee(employee: EmployeeApiData): ApprovalPerson {
|
||||
return {
|
||||
id: String(employee.user?.id || employee.user_id), // User.id 사용 (결재선에 필요)
|
||||
name: employee.user?.name || '',
|
||||
position: getPositionLabelForEmployee(employee.position_key),
|
||||
department: employee.department?.name || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* @param files 업로드할 파일 배열
|
||||
* @returns 업로드된 파일 정보 배열
|
||||
*
|
||||
* NOTE: 파일 업로드는 multipart/form-data가 필요하므로 serverFetch 대신 직접 fetch 사용
|
||||
*/
|
||||
export async function uploadFiles(files: File[]): Promise<{
|
||||
success: boolean;
|
||||
data?: UploadedFile[];
|
||||
error?: string;
|
||||
}> {
|
||||
if (files.length === 0) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
// 파일을 하나씩 업로드 (멀티파트 폼)
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
// Content-Type은 자동 설정됨 (multipart/form-data)
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] File upload error:', response.status);
|
||||
return { success: false, error: `파일 업로드 실패: ${file.name}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
// API 응답 필드: id, display_name, file_path, file_size, mime_type
|
||||
uploadedFiles.push({
|
||||
id: result.data.id,
|
||||
name: result.data.display_name || file.name,
|
||||
url: `/api/proxy/files/${result.data.id}/download`,
|
||||
size: result.data.file_size,
|
||||
mime_type: result.data.mime_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: uploadedFiles };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] uploadFiles error:', error);
|
||||
return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용견적서 항목 조회
|
||||
*/
|
||||
export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
} | null> {
|
||||
const result = await executeServerAction<ExpenseEstimateApiResponse>({
|
||||
url: buildApiUrl('/api/v1/reports/expense-estimate', { year_month: yearMonth }),
|
||||
errorMessage: '비용견적서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return null;
|
||||
return {
|
||||
items: result.data.items.map(transformExpenseEstimateItem),
|
||||
totalExpense: result.data.total_expense,
|
||||
accountBalance: result.data.account_balance,
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 목록 조회 (결재선/참조 선택용)
|
||||
*/
|
||||
export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
const result = await executeServerAction<{ data: EmployeeApiData[] }>({
|
||||
url: buildApiUrl('/api/v1/employees', { per_page: 100, search }),
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data?.data) return [];
|
||||
return result.data.data.map(transformEmployee);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 생성 (임시저장)
|
||||
*/
|
||||
export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
status: 'draft',
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: buildApiUrl('/api/v1/approvals'),
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 저장에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 상신
|
||||
*/
|
||||
export async function submitApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/submit`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '문서 상신에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 생성 및 상신 (한번에)
|
||||
*/
|
||||
export async function createAndSubmitApproval(formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 먼저 문서 생성
|
||||
const createResult = await createApproval(formData);
|
||||
if (!createResult.success || !createResult.data) {
|
||||
return createResult;
|
||||
}
|
||||
|
||||
// 2. 상신
|
||||
const submitResult = await submitApproval(createResult.data.id);
|
||||
if (!submitResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: submitResult.error || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: createResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createAndSubmitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 조회 (수정 모드용)
|
||||
*/
|
||||
export async function getApprovalById(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: DocumentFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformApiToFormData(result.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 수정
|
||||
*/
|
||||
export async function updateApproval(id: number, formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'PATCH',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 수정에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 수정 및 상신
|
||||
*/
|
||||
export async function updateAndSubmitApproval(id: number, formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 먼저 문서 수정
|
||||
const updateResult = await updateApproval(id, formData);
|
||||
if (!updateResult.success) {
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
// 2. 상신
|
||||
const submitResult = await submitApproval(id);
|
||||
if (!submitResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: submitResult.error || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updateResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateAndSubmitApproval error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 삭제
|
||||
*/
|
||||
export async function deleteApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '문서 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 내부 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 문서 제목 생성
|
||||
*/
|
||||
function getDocumentTitle(formData: DocumentFormData): string {
|
||||
switch (formData.basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return formData.proposalData?.title || '품의서';
|
||||
case 'expenseReport':
|
||||
return `지출결의서 - ${formData.expenseReportData?.requestDate || ''}`;
|
||||
case 'expenseEstimate':
|
||||
return `지출 예상 내역서`;
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드를 한글 라벨로 변환
|
||||
*/
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 폼 데이터로 변환
|
||||
*/
|
||||
function transformApiToFormData(apiData: {
|
||||
id: number;
|
||||
document_number: string;
|
||||
form_code?: string; // 이전 호환성
|
||||
form?: { // 현재 API 구조
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category?: string;
|
||||
template?: Record<string, unknown>;
|
||||
};
|
||||
title: string;
|
||||
status: string;
|
||||
content: Record<string, unknown>;
|
||||
steps?: Array<{
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
position?: string;
|
||||
department?: { name: string };
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
display_name?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
created_at: string;
|
||||
requester?: {
|
||||
name: string;
|
||||
};
|
||||
drafter?: {
|
||||
id: number;
|
||||
name: string;
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
display_name?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
};
|
||||
}): DocumentFormData {
|
||||
// form.code를 우선 사용, 없으면 form_code (이전 호환성)
|
||||
const formCode = apiData.form?.code || apiData.form_code || 'proposal';
|
||||
const documentType = formCode as 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
const content = apiData.content || {};
|
||||
|
||||
// 결재선 및 참조자 분리
|
||||
const approvalLine: ApprovalPerson[] = [];
|
||||
const references: ApprovalPerson[] = [];
|
||||
|
||||
if (apiData.steps) {
|
||||
for (const step of apiData.steps) {
|
||||
if (step.approver) {
|
||||
// tenantProfile에서 직책/부서 정보 추출 (우선), 없으면 기존 필드 사용
|
||||
const tenantProfile = step.approver.tenant_profile;
|
||||
const position = tenantProfile?.position_key
|
||||
? getPositionLabel(tenantProfile.position_key)
|
||||
: (step.approver.position || '');
|
||||
const department = tenantProfile?.department?.name
|
||||
|| step.approver.department?.name
|
||||
|| '';
|
||||
|
||||
const person: ApprovalPerson = {
|
||||
id: String(step.approver.id),
|
||||
name: step.approver.name,
|
||||
position,
|
||||
department,
|
||||
};
|
||||
|
||||
// 'approval'과 'agreement' 모두 결재선에 포함
|
||||
if (step.step_type === 'approval' || step.step_type === 'agreement') {
|
||||
approvalLine.push(person);
|
||||
} else if (step.step_type === 'reference') {
|
||||
references.push(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 정보 (drafter에서 tenantProfile 정보 추출)
|
||||
const drafterProfile = apiData.drafter?.tenant_profile;
|
||||
const basicInfo = {
|
||||
drafter: apiData.drafter?.name || apiData.requester?.name || '',
|
||||
drafterPosition: drafterProfile?.position_key
|
||||
? getPositionLabel(drafterProfile.position_key)
|
||||
: '',
|
||||
drafterDepartment: drafterProfile?.department?.name || '',
|
||||
draftDate: apiData.created_at,
|
||||
documentNo: apiData.document_number,
|
||||
documentType,
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 추출
|
||||
const existingFiles = (content.files as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
}>) || [];
|
||||
const uploadedFiles: UploadedFile[] = existingFiles.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
// URL이 없거나 상대 경로인 경우 다운로드 URL 생성
|
||||
url: f.url?.startsWith('http')
|
||||
? f.url
|
||||
: `/api/proxy/files/${f.id}/download`,
|
||||
size: f.size,
|
||||
mime_type: f.mime_type,
|
||||
}));
|
||||
|
||||
// 문서 유형별 데이터 변환
|
||||
let proposalData;
|
||||
let expenseReportData;
|
||||
let expenseEstimateData;
|
||||
|
||||
if (documentType === 'proposal') {
|
||||
proposalData = {
|
||||
vendorId: (content.vendorId as string) || '',
|
||||
vendor: (content.vendor as string) || '',
|
||||
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
|
||||
title: (content.title as string) || '',
|
||||
description: (content.description as string) || '',
|
||||
reason: (content.reason as string) || '',
|
||||
estimatedCost: (content.estimatedCost as number) || 0,
|
||||
attachments: [],
|
||||
uploadedFiles,
|
||||
};
|
||||
} else if (documentType === 'expenseReport') {
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
|
||||
expenseReportData = {
|
||||
requestDate: (content.requestDate as string) || '',
|
||||
paymentDate: (content.paymentDate as string) || '',
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note || '',
|
||||
})),
|
||||
cardId: (content.cardId as string) || '',
|
||||
totalAmount: (content.totalAmount as number) || 0,
|
||||
attachments: [],
|
||||
uploadedFiles,
|
||||
};
|
||||
} else if (documentType === 'expenseEstimate') {
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
checked: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
|
||||
expenseEstimateData = {
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
checked: item.checked || false,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
memo: item.memo || '',
|
||||
})),
|
||||
totalExpense: (content.totalExpense as number) || 0,
|
||||
accountBalance: (content.accountBalance as number) || 0,
|
||||
finalDifference: (content.finalDifference as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
references,
|
||||
proposalData,
|
||||
expenseReportData,
|
||||
expenseEstimateData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 내용 생성 (JSON)
|
||||
* @param formData 폼 데이터
|
||||
* @param uploadedFiles 새로 업로드된 파일 목록
|
||||
*/
|
||||
function getDocumentContent(
|
||||
formData: DocumentFormData,
|
||||
uploadedFiles: UploadedFile[] = []
|
||||
): Record<string, unknown> {
|
||||
// 기존 업로드 파일 + 새로 업로드된 파일 합치기
|
||||
const existingFiles = formData.proposalData?.uploadedFiles
|
||||
|| formData.expenseReportData?.uploadedFiles
|
||||
|| [];
|
||||
const allFiles = [...existingFiles, ...uploadedFiles];
|
||||
|
||||
switch (formData.basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return {
|
||||
vendorId: formData.proposalData?.vendorId,
|
||||
vendor: formData.proposalData?.vendor,
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate,
|
||||
title: formData.proposalData?.title,
|
||||
description: formData.proposalData?.description,
|
||||
reason: formData.proposalData?.reason,
|
||||
estimatedCost: formData.proposalData?.estimatedCost,
|
||||
files: allFiles,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
requestDate: formData.expenseReportData?.requestDate,
|
||||
paymentDate: formData.expenseReportData?.paymentDate,
|
||||
items: formData.expenseReportData?.items,
|
||||
cardId: formData.expenseReportData?.cardId,
|
||||
totalAmount: formData.expenseReportData?.totalAmount,
|
||||
files: allFiles,
|
||||
};
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
items: formData.expenseEstimateData?.items,
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 기안 문서 작성/수정 페이지 설정
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
*/
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
export const documentCreateConfig: DetailConfig = {
|
||||
title: '문서 작성',
|
||||
description: '새로운 결재 문서를 작성합니다',
|
||||
icon: FileText,
|
||||
basePath: '/approval/draft',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: false,
|
||||
showDelete: false, // 커스텀 삭제 버튼 사용
|
||||
showSave: false, // 상신/임시저장 버튼 사용
|
||||
},
|
||||
};
|
||||
|
||||
export const documentEditConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서',
|
||||
description: '기존 결재 문서를 수정합니다',
|
||||
// actions는 documentCreateConfig에서 상속 (커스텀 버튼 사용)
|
||||
};
|
||||
|
||||
export const documentCopyConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서',
|
||||
description: '복제된 문서를 수정 후 상신합니다',
|
||||
};
|
||||
@@ -1,631 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { format } from 'date-fns';
|
||||
import { Trash2, Send, Save, Eye } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IntegratedDetailTemplate, type ActionItem } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import {
|
||||
documentCreateConfig,
|
||||
documentEditConfig,
|
||||
documentCopyConfig,
|
||||
} from './documentCreateConfig';
|
||||
import {
|
||||
getExpenseEstimateItems,
|
||||
createApproval,
|
||||
createAndSubmitApproval,
|
||||
getApprovalById,
|
||||
updateApproval,
|
||||
updateAndSubmitApproval,
|
||||
deleteApproval,
|
||||
getEmployees,
|
||||
} from './actions';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { BasicInfoSection } from './BasicInfoSection';
|
||||
import { ApprovalLineSection } from './ApprovalLineSection';
|
||||
import { ReferenceSection } from './ReferenceSection';
|
||||
import { ProposalForm } from './ProposalForm';
|
||||
import { ExpenseReportForm } from './ExpenseReportForm';
|
||||
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type {
|
||||
DocumentType as ModalDocumentType,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
BasicInfo,
|
||||
ApprovalPerson,
|
||||
ProposalData,
|
||||
ExpenseReportData,
|
||||
ExpenseEstimateData,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
|
||||
const getInitialBasicInfo = (): BasicInfo => ({
|
||||
drafter: '', // 클라이언트에서 currentUser로 설정
|
||||
draftDate: '', // 클라이언트에서 설정
|
||||
documentNo: '',
|
||||
documentType: 'proposal',
|
||||
});
|
||||
|
||||
const getInitialProposalData = (): ProposalData => ({
|
||||
vendorId: '',
|
||||
vendor: '',
|
||||
vendorPaymentDate: '', // 클라이언트에서 설정
|
||||
title: '',
|
||||
description: '',
|
||||
reason: '',
|
||||
estimatedCost: 0,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const getInitialExpenseReportData = (): ExpenseReportData => ({
|
||||
requestDate: '', // 클라이언트에서 설정
|
||||
paymentDate: '', // 클라이언트에서 설정
|
||||
items: [],
|
||||
cardId: '',
|
||||
totalAmount: 0,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
|
||||
items: [],
|
||||
totalExpense: 0,
|
||||
accountBalance: 10000000,
|
||||
finalDifference: 10000000,
|
||||
});
|
||||
|
||||
export function DocumentCreate() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const { canCreate, canDelete } = usePermission();
|
||||
|
||||
// 수정 모드 / 복제 모드 상태
|
||||
const documentId = searchParams.get('id');
|
||||
const mode = searchParams.get('mode');
|
||||
const copyFromId = searchParams.get('copyFrom');
|
||||
const isEditMode = mode === 'edit' && !!documentId;
|
||||
const isCopyMode = !!copyFromId;
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
|
||||
|
||||
// 상태 관리
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
|
||||
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
|
||||
const [references, setReferences] = useState<ApprovalPerson[]>([]);
|
||||
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
|
||||
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
|
||||
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false);
|
||||
|
||||
// 복제 모드 toast 중복 호출 방지
|
||||
const copyToastShownRef = useRef(false);
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Hydration 불일치 방지: 클라이언트에서만 날짜 초기화
|
||||
useEffect(() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
|
||||
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
|
||||
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
|
||||
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
|
||||
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
drafter: prev.drafter || userName,
|
||||
draftDate: prev.draftDate || now,
|
||||
}));
|
||||
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
|
||||
setExpenseReportData(prev => ({
|
||||
...prev,
|
||||
requestDate: prev.requestDate || today,
|
||||
paymentDate: prev.paymentDate || today,
|
||||
}));
|
||||
}, [currentUser?.name]);
|
||||
|
||||
// 미리보기 모달 상태
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
// ===== DevFill: 자동 입력 기능 =====
|
||||
useDevFill('purchaseApproval', useCallback(async () => {
|
||||
if (!isEditMode && !isCopyMode) {
|
||||
const mockData = generatePurchaseApprovalData();
|
||||
|
||||
// 직원 목록 가져오기
|
||||
const employees = await getEmployees();
|
||||
|
||||
// 거래처 목록 가져오기 (매입 거래처만)
|
||||
const clientsResult = await getClients({ size: 1000, only_active: true });
|
||||
const purchaseClients = clientsResult.success
|
||||
? clientsResult.data
|
||||
.filter((v) => v.category === 'purchase' || v.category === 'both')
|
||||
.map((v) => ({ id: v.id, name: v.vendorName }))
|
||||
: [];
|
||||
|
||||
// 랜덤 거래처 선택
|
||||
const randomClient = purchaseClients.length > 0
|
||||
? purchaseClients[Math.floor(Math.random() * purchaseClients.length)]
|
||||
: null;
|
||||
|
||||
// localStorage에서 실제 로그인 사용자 이름 가져오기 (우측 상단 표시와 동일한 소스)
|
||||
const userDataStr = localStorage.getItem("user");
|
||||
const currentUserName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name;
|
||||
|
||||
// 현재 사용자 이름으로 결재선에 추가할 직원 찾기
|
||||
const approver = currentUserName
|
||||
? employees.find(e => e.name === currentUserName)
|
||||
: null;
|
||||
|
||||
// 경리/회계/재무 부서 직원 중 랜덤 1명 참조 추가
|
||||
const accountingDepts = ['경리', '회계', '재무'];
|
||||
const accountingStaff = employees.filter(e =>
|
||||
accountingDepts.some(dept => e.department?.includes(dept))
|
||||
);
|
||||
const randomReference = accountingStaff.length > 0
|
||||
? accountingStaff[Math.floor(Math.random() * accountingStaff.length)]
|
||||
: null;
|
||||
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
...mockData.basicInfo,
|
||||
drafter: currentUserName || prev.drafter,
|
||||
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
|
||||
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
|
||||
}));
|
||||
|
||||
// 결재선: 현재 사용자가 직원 목록에 있으면 설정, 없으면 랜덤 1명
|
||||
if (approver) {
|
||||
setApprovalLine([approver]);
|
||||
} else if (employees.length > 0) {
|
||||
const randomApprover = employees[Math.floor(Math.random() * employees.length)];
|
||||
setApprovalLine([randomApprover]);
|
||||
}
|
||||
|
||||
// 참조: 경리/회계/재무 직원이 있으면 설정
|
||||
if (randomReference) {
|
||||
setReferences([randomReference]);
|
||||
}
|
||||
|
||||
setProposalData(prev => ({
|
||||
...prev,
|
||||
...mockData.proposalData,
|
||||
// 실제 API 거래처로 덮어쓰기
|
||||
vendorId: randomClient?.id || '',
|
||||
vendor: randomClient?.name || '',
|
||||
}));
|
||||
toast.success('지출결의서 데이터가 자동 입력되었습니다.');
|
||||
}
|
||||
}, [isEditMode, isCopyMode, currentUser?.name]));
|
||||
|
||||
// 수정 모드: 문서 로드
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !documentId) return;
|
||||
|
||||
const loadDocument = async () => {
|
||||
setIsLoadingDocument(true);
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(documentId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
|
||||
setBasicInfo(loadedBasicInfo);
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
} else {
|
||||
toast.error(result.error || '문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
} finally {
|
||||
setIsLoadingDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
}, [isEditMode, documentId, router]);
|
||||
|
||||
// 복제 모드: 원본 문서 로드 후 새 문서로 설정
|
||||
useEffect(() => {
|
||||
if (!isCopyMode || !copyFromId) return;
|
||||
|
||||
const loadDocumentForCopy = async () => {
|
||||
setIsLoadingDocument(true);
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(copyFromId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
|
||||
// 복제: 문서번호 초기화, 기안일 현재 시간으로
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
setBasicInfo({
|
||||
...loadedBasicInfo,
|
||||
documentNo: '', // 새 문서이므로 문서번호 초기화
|
||||
draftDate: now,
|
||||
});
|
||||
|
||||
// 결재선/참조는 그대로 유지
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
|
||||
// 문서 내용 복제
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
|
||||
// React.StrictMode에서 useEffect 두 번 실행으로 인한 toast 중복 방지
|
||||
if (!copyToastShownRef.current) {
|
||||
copyToastShownRef.current = true;
|
||||
toast.info('문서가 복제되었습니다. 수정 후 상신해주세요.');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '원본 문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document for copy:', error);
|
||||
toast.error('원본 문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
} finally {
|
||||
setIsLoadingDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocumentForCopy();
|
||||
}, [isCopyMode, copyFromId, router]);
|
||||
|
||||
// 비용견적서 항목 로드
|
||||
const loadExpenseEstimateItems = useCallback(async () => {
|
||||
setIsLoadingEstimate(true);
|
||||
try {
|
||||
const result = await getExpenseEstimateItems();
|
||||
if (result) {
|
||||
setExpenseEstimateData({
|
||||
items: result.items,
|
||||
totalExpense: result.totalExpense,
|
||||
accountBalance: result.accountBalance,
|
||||
finalDifference: result.finalDifference,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load expense estimate items:', error);
|
||||
toast.error('비용견적서 항목을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoadingEstimate(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 유형이 비용견적서로 변경되면 항목 로드
|
||||
useEffect(() => {
|
||||
if (basicInfo.documentType === 'expenseEstimate' && expenseEstimateData.items.length === 0) {
|
||||
loadExpenseEstimateItems();
|
||||
}
|
||||
}, [basicInfo.documentType, expenseEstimateData.items.length, loadExpenseEstimateItems]);
|
||||
|
||||
// 폼 데이터 수집
|
||||
const getFormData = useCallback(() => {
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
references,
|
||||
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
|
||||
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
|
||||
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||
};
|
||||
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
|
||||
|
||||
// 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
|
||||
// 수정 모드: 실제 문서 삭제
|
||||
if (isEditMode && documentId) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteApproval(parseInt(documentId));
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서가 삭제되었습니다.');
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('문서 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 새 문서: 그냥 뒤로가기
|
||||
router.back();
|
||||
}
|
||||
}, [router, isEditMode, documentId]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (approvalLine.length === 0) {
|
||||
toast.error('결재선을 지정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 수정 후 상신
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('수정 및 상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 생성 후 상신
|
||||
const result = await createAndSubmitApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('문서 상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [approvalLine, getFormData, router, isEditMode, documentId]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 기존 문서 업데이트
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 임시저장
|
||||
const result = await createApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('임시저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// 문서번호 업데이트
|
||||
if (result.data?.documentNo) {
|
||||
setBasicInfo(prev => ({ ...prev, documentNo: result.data!.documentNo }));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '임시저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save draft error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [getFormData, isEditMode, documentId]);
|
||||
|
||||
// 미리보기 핸들러
|
||||
const handlePreview = useCallback(() => {
|
||||
setIsPreviewOpen(true);
|
||||
}, []);
|
||||
|
||||
// 미리보기용 데이터 변환
|
||||
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: basicInfo.drafter,
|
||||
position: '사원',
|
||||
department: '개발팀',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = approvalLine.map((a, index) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
department: a.department,
|
||||
status: (index === 0 ? 'pending' : 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||
}));
|
||||
|
||||
switch (basicInfo.documentType) {
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
items: expenseEstimateData.items.map(item => ({
|
||||
id: item.id,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
account: item.memo || '',
|
||||
})),
|
||||
totalExpense: expenseEstimateData.totalExpense,
|
||||
accountBalance: expenseEstimateData.accountBalance,
|
||||
finalDifference: expenseEstimateData.finalDifference,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
requestDate: expenseReportData.requestDate,
|
||||
paymentDate: expenseReportData.paymentDate,
|
||||
items: expenseReportData.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
no: index + 1,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note,
|
||||
})),
|
||||
cardInfo: expenseReportData.cardId || '-',
|
||||
totalAmount: expenseReportData.totalAmount,
|
||||
attachments: expenseReportData.attachments.map(f => f.name),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default: {
|
||||
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
|
||||
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
const newFileUrls = proposalData.attachments.map(f => URL.createObjectURL(f));
|
||||
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
vendor: proposalData.vendor || '-',
|
||||
vendorPaymentDate: proposalData.vendorPaymentDate,
|
||||
title: proposalData.title || '(제목 없음)',
|
||||
description: proposalData.description || '-',
|
||||
reason: proposalData.reason || '-',
|
||||
estimatedCost: proposalData.estimatedCost,
|
||||
attachments: [...uploadedFileUrls, ...newFileUrls],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
|
||||
|
||||
// 문서 유형별 폼 렌더링
|
||||
const renderDocumentTypeForm = () => {
|
||||
switch (basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalForm data={proposalData} onChange={setProposalData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} isLoading={isLoadingEstimate} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 모드에 맞는 config 선택
|
||||
const currentConfig = isEditMode
|
||||
? documentEditConfig
|
||||
: isCopyMode
|
||||
? documentCopyConfig
|
||||
: documentCreateConfig;
|
||||
|
||||
// 헤더 액션 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용)
|
||||
const headerActionItems = useMemo<ActionItem[]>(() => [
|
||||
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline' },
|
||||
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isPending },
|
||||
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isPending || !canCreate, loading: isPending },
|
||||
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isPending, loading: isPending },
|
||||
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
|
||||
|
||||
{/* 결재선 */}
|
||||
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
|
||||
|
||||
{/* 참조 */}
|
||||
<ReferenceSection data={references} onChange={setReferences} />
|
||||
|
||||
{/* 문서 유형별 폼 */}
|
||||
{renderDocumentTypeForm()}
|
||||
</div>
|
||||
);
|
||||
}, [basicInfo, approvalLine, references, renderDocumentTypeForm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={currentConfig}
|
||||
mode={isEditMode ? 'edit' : 'create'}
|
||||
isLoading={isLoadingDocument}
|
||||
onBack={handleBack}
|
||||
renderForm={renderFormContent}
|
||||
headerActionItems={headerActionItems}
|
||||
/>
|
||||
|
||||
{/* 미리보기 모달 */}
|
||||
<DocumentDetailModal
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={setIsPreviewOpen}
|
||||
documentType={basicInfo.documentType as ModalDocumentType}
|
||||
data={getPreviewData()}
|
||||
mode="draft"
|
||||
documentStatus="draft"
|
||||
onCopy={() => {
|
||||
// 복제: 현재 데이터를 기반으로 새 문서 등록 화면으로 이동
|
||||
if (documentId) {
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${documentId}`);
|
||||
setIsPreviewOpen(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setIsPreviewOpen(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="문서 삭제"
|
||||
description="작성 중인 문서를 삭제하시겠습니까?"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// ===== 문서 작성 타입 정의 =====
|
||||
|
||||
// 업로드된 파일 정보
|
||||
export interface UploadedFile {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
// 문서 유형
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
|
||||
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expenseReport', label: '지출결의서' },
|
||||
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
|
||||
];
|
||||
|
||||
// 결재자/참조자 정보
|
||||
export interface ApprovalPerson {
|
||||
id: string;
|
||||
department: string;
|
||||
position: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 기본 정보
|
||||
export interface BasicInfo {
|
||||
drafter: string;
|
||||
drafterPosition?: string;
|
||||
drafterDepartment?: string;
|
||||
draftDate: string;
|
||||
documentNo: string;
|
||||
documentType: DocumentType;
|
||||
}
|
||||
|
||||
// 품의서 데이터
|
||||
export interface ProposalData {
|
||||
vendorId: string; // 거래처 ID (API 연동)
|
||||
vendor: string; // 거래처명 (표시용)
|
||||
vendorPaymentDate: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reason: string;
|
||||
estimatedCost: number;
|
||||
attachments: File[]; // 새로 추가할 파일
|
||||
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
|
||||
}
|
||||
|
||||
// 지출결의서 항목
|
||||
export interface ExpenseReportItem {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 지출결의서 데이터
|
||||
export interface ExpenseReportData {
|
||||
requestDate: string;
|
||||
paymentDate: string;
|
||||
items: ExpenseReportItem[];
|
||||
cardId: string;
|
||||
totalAmount: number;
|
||||
attachments: File[]; // 새로 추가할 파일
|
||||
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
export interface ExpenseEstimateData {
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
}
|
||||
|
||||
// 전체 문서 데이터
|
||||
export interface DocumentFormData {
|
||||
basicInfo: BasicInfo;
|
||||
approvalLine: ApprovalPerson[];
|
||||
references: ApprovalPerson[];
|
||||
proposalData?: ProposalData;
|
||||
expenseReportData?: ExpenseReportData;
|
||||
expenseEstimateData?: ExpenseEstimateData;
|
||||
}
|
||||
|
||||
// 카드 옵션
|
||||
export const CARD_OPTIONS = [
|
||||
{ value: 'ibk-1234', label: 'IBK기업카드_1234 (카드명)' },
|
||||
{ value: 'shinhan-5678', label: '신한카드_5678 (카드명)' },
|
||||
{ value: 'kb-9012', label: 'KB국민카드_9012 (카드명)' },
|
||||
];
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import type { Approver } from './types';
|
||||
|
||||
interface ApprovalLineBoxProps {
|
||||
drafter: Approver;
|
||||
approvers: Approver[];
|
||||
}
|
||||
|
||||
export function ApprovalLineBox({ drafter, approvers }: ApprovalLineBoxProps) {
|
||||
const getStatusIcon = (status: Approver['status']) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <CheckCircle className="w-3 h-3 text-green-600" />;
|
||||
case 'rejected':
|
||||
return <XCircle className="w-3 h-3 text-red-600" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-3 h-3 text-yellow-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 text-xs">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||
구분
|
||||
</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||
작성
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`bg-gray-100 p-1.5 text-center font-medium ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{approver.status === 'approved' ? '승인' : approver.status === 'rejected' ? '반려' : '결재'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이름 행 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">이름</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="p-1.5 text-center border-r border-gray-300 flex items-center justify-center gap-1">
|
||||
{drafter.name}
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`p-1.5 text-center flex items-center justify-center gap-1 ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{getStatusIcon(approver.status)}
|
||||
{approver.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 행 */}
|
||||
<div className="grid grid-cols-[60px_1fr]">
|
||||
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">부서명</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="p-1.5 text-center border-r border-gray-300">
|
||||
{drafter.department}
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`p-1.5 text-center ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{approver.department}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { LinkedDocumentContent } from './LinkedDocumentContent';
|
||||
import type {
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 문서 상세 모달 V2
|
||||
*
|
||||
* DocumentViewer를 사용하여 통합 UI 제공
|
||||
* - 줌/드래그 기능 추가
|
||||
* - 모드에 따른 버튼 자동 설정
|
||||
*/
|
||||
export function DocumentDetailModalV2({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
data,
|
||||
mode = 'inbox',
|
||||
documentStatus,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onApprove,
|
||||
onReject,
|
||||
onSubmit,
|
||||
}: DocumentDetailModalProps) {
|
||||
// 문서 타입별 제목
|
||||
const getDocumentTitle = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return '품의서';
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
case 'document':
|
||||
return (data as LinkedDocumentData).templateName || '문서 결재';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
};
|
||||
|
||||
// 모드에 따른 프리셋 결정
|
||||
const getPreset = () => {
|
||||
// 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄
|
||||
if (mode === 'draft' && documentStatus === 'draft') {
|
||||
return 'approval-draft' as const;
|
||||
}
|
||||
// 결재함 모드: 수정, 반려, 승인, 인쇄
|
||||
if (mode === 'inbox') {
|
||||
return 'approval-inbox' as const;
|
||||
}
|
||||
// 그 외 (참조함 등): 인쇄만
|
||||
return 'readonly' as const;
|
||||
};
|
||||
|
||||
// 문서 콘텐츠 렌더링
|
||||
const renderDocument = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalDocument data={data as ProposalDocumentData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
case 'document':
|
||||
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title={getDocumentTitle()}
|
||||
subtitle={`${getDocumentTitle()} 상세`}
|
||||
preset={getPreset()}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onEdit={onEdit}
|
||||
onCopy={onCopy}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{renderDocument()}
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지출 예상 내역서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ExpenseEstimateDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseEstimateDocumentProps {
|
||||
data: ExpenseEstimateDocumentData;
|
||||
}
|
||||
|
||||
export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps) {
|
||||
|
||||
// 월별 그룹핑
|
||||
const groupedByMonth = data.items.reduce((acc, item) => {
|
||||
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof data.items>);
|
||||
|
||||
const getMonthSubtotal = (monthItems: typeof data.items) => {
|
||||
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const getMonthLabel = (month: string) => {
|
||||
const [year, mon] = month.split('-');
|
||||
return `${year}년 ${parseInt(mon)}월 계`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="지출 예상 내역서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 지출 예상 내역서 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
지출 예상 내역서 목록
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-32">예상 지급일</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">항목</th>
|
||||
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">지출금액</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300 w-24">거래처</th>
|
||||
<th className="p-2 text-left font-medium w-40">계좌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||
<Fragment key={month}>
|
||||
{monthItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.expectedPaymentDate}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.category}</td>
|
||||
<td className="p-2 text-right border-r border-gray-300 text-blue-600 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.vendor}</td>
|
||||
<td className="p-2">{item.account}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 월별 소계 */}
|
||||
<tr className="bg-pink-50 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-2 font-medium border-r border-gray-300">
|
||||
{getMonthLabel(month)}
|
||||
</td>
|
||||
<td className="p-2 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||
</td>
|
||||
<td colSpan={2} className="p-2"></td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 지출 합계 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">지출 합계</td>
|
||||
<td className="p-3 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||
{formatCurrency(data.totalExpense)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
|
||||
{/* 계좌 잔액 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">계좌 잔액</td>
|
||||
<td className="p-3 text-right border-r border-gray-300 font-bold">
|
||||
{formatCurrency(data.accountBalance)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
|
||||
{/* 최종 차액 */}
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">최종 차액</td>
|
||||
<td className={`p-3 text-right border-r border-gray-300 font-bold ${data.finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(data.finalDifference)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지출결의서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ExpenseReportDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseReportDocumentProps {
|
||||
data: ExpenseReportDocumentData;
|
||||
}
|
||||
|
||||
export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="지출결의서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 지출 요청일 / 결제일 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
지출 요청일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.requestDate || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
결제일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.paymentDate || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지출결의서 내역 */}
|
||||
<div className="border-b border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
지출결의서 내역
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">No.</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">적요</th>
|
||||
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">금액</th>
|
||||
<th className="p-2 text-left font-medium w-40">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length > 0 ? (
|
||||
data.items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.description}</td>
|
||||
<td className="p-2 text-right border-r border-gray-300">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
<td className="p-2">{item.note}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{[1, 2, 3].map((num) => (
|
||||
<tr key={num} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{num}</td>
|
||||
<td className="p-2 border-r border-gray-300"> </td>
|
||||
<td className="p-2 border-r border-gray-300"> </td>
|
||||
<td className="p-2"> </td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 법인카드 / 총 비용 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
법인카드
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.cardInfo || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
총 비용
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm font-semibold">
|
||||
{formatCurrency(data.totalAmount)}원
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 */}
|
||||
<div>
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
참고 이미지
|
||||
</div>
|
||||
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{data.attachments.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
첨부된 이미지가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 연결 문서 콘텐츠 컴포넌트
|
||||
*
|
||||
* 문서관리에서 생성된 검사 성적서, 작업일지 등을
|
||||
* 결재함 모달에서 렌더링합니다.
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { LinkedDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Paperclip } from 'lucide-react';
|
||||
|
||||
interface LinkedDocumentContentProps {
|
||||
data: LinkedDocumentData;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '임시저장',
|
||||
PENDING: '진행중',
|
||||
APPROVED: '승인완료',
|
||||
REJECTED: '반려',
|
||||
CANCELLED: '회수',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title={data.templateName || '문서 결재'}
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt?.substring(0, 10) || '-'}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 기본 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
문서 제목
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
양식
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-500" />
|
||||
{data.templateName || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
상태
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">
|
||||
<Badge className={STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'}>
|
||||
{STATUS_LABELS[data.status] || data.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 데이터 */}
|
||||
{data.documentData.length > 0 && (
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
문서 내용
|
||||
</div>
|
||||
<div className="divide-y divide-gray-300">
|
||||
{data.documentData.map((field, index) => (
|
||||
<div key={index} className="flex">
|
||||
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
{field.fieldLabel}
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm whitespace-pre-wrap">
|
||||
{renderFieldValue(field.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{data.attachments && data.attachments.length > 0 && (
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
첨부파일
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{data.attachments.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{file.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'string') return value || '-';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'boolean') return value ? '예' : '아니오';
|
||||
if (Array.isArray(value)) return value.join(', ') || '-';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품의서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ProposalDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ProposalDocumentProps {
|
||||
data: ProposalDocumentData;
|
||||
}
|
||||
|
||||
export function ProposalDocument({ data }: ProposalDocumentProps) {
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="품의서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 구매처 정보 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
구매처
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.vendor || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
구매처 결제일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.vendorPaymentDate || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
제목
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 내역 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
품의 내역
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||
{data.description || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 사유 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
품의 사유
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||
{data.reason || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예상 비용 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
예상 비용
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm font-semibold">
|
||||
{formatCurrency(data.estimatedCost)}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 */}
|
||||
<div>
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
참고 이미지
|
||||
</div>
|
||||
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{data.attachments.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
첨부된 이미지가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
X as XIcon,
|
||||
CheckCircle,
|
||||
Printer,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type {
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from './types';
|
||||
|
||||
export function DocumentDetailModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
data,
|
||||
mode = 'inbox', // 기본값: 결재함 (승인/반려 표시)
|
||||
documentStatus,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onApprove,
|
||||
onReject,
|
||||
onSubmit,
|
||||
}: DocumentDetailModalProps) {
|
||||
// 기안함 모드에서 임시저장 상태일 때만 수정/상신 가능
|
||||
const getDocumentTitle = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return '품의서';
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: `${getDocumentTitle()} 인쇄` });
|
||||
};
|
||||
|
||||
const renderDocument = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalDocument data={data as ProposalDocumentData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{getDocumentTitle()} 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">{getDocumentTitle()} 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
|
||||
{mode === 'draft' && documentStatus === 'draft' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onCopy}>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
복제
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={onSubmit} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
상신
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기안함 모드 + 결재대기 이후 상태: 인쇄만 (버튼 없음, 아래 인쇄 버튼만 표시) */}
|
||||
|
||||
{/* 결재함 모드: 수정, 반려, 승인, 인쇄 */}
|
||||
{mode === 'inbox' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onReject} className="text-red-600 hover:text-red-700">
|
||||
<XIcon className="h-4 w-4 mr-1" />
|
||||
반려
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={onApprove} className="bg-blue-600 hover:bg-blue-700">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
승인
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
|
||||
{/* TODO: 공유 기능 추가 예정 - PDF 다운로드, 이메일, 팩스, 카카오톡 공유 */}
|
||||
{/* <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
공유
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleSharePdf}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
PDF 다운로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareEmail}>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
이메일
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareFax}>
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
팩스
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareKakao}>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
카카오톡
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> */}
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
|
||||
{renderDocument()}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types and components
|
||||
export type { DocumentType, DocumentDetailModalProps } from './types';
|
||||
export { ProposalDocument } from './ProposalDocument';
|
||||
export { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
export { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
|
||||
// V2 - DocumentViewer 기반
|
||||
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
|
||||
@@ -1,117 +0,0 @@
|
||||
// ===== 문서 상세 모달 타입 정의 =====
|
||||
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
|
||||
|
||||
// 결재자 정보
|
||||
export interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
// 품의서 데이터
|
||||
export interface ProposalDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
vendor: string;
|
||||
vendorPaymentDate: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reason: string;
|
||||
estimatedCost: number;
|
||||
attachments?: string[];
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 지출결의서 항목
|
||||
export interface ExpenseReportItem {
|
||||
id: string;
|
||||
no: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 지출결의서 데이터
|
||||
export interface ExpenseReportDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
requestDate: string;
|
||||
paymentDate: string;
|
||||
items: ExpenseReportItem[];
|
||||
cardInfo: string;
|
||||
totalAmount: number;
|
||||
attachments?: string[];
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
export interface ExpenseEstimateDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
|
||||
export interface LinkedDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{
|
||||
fieldKey: string;
|
||||
fieldLabel: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
attachments?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 문서 상세 모달 모드
|
||||
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
|
||||
|
||||
// 문서 상태 (기안함 기준)
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 문서 상세 모달 Props
|
||||
export interface DocumentDetailModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentType;
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
|
||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
||||
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
||||
onEdit?: () => void;
|
||||
onCopy?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onSubmit?: () => void; // 상신 콜백
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* 기안함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/drafts - 기안함 목록 조회
|
||||
* - GET /api/v1/approvals/drafts/summary - 기안함 현황 카드
|
||||
* - GET /api/v1/approvals/{id} - 결재 문서 상세
|
||||
* - DELETE /api/v1/approvals/{id} - 결재 문서 삭제 (임시저장만)
|
||||
* - POST /api/v1/approvals/{id}/submit - 결재 상신
|
||||
* - POST /api/v1/approvals/{id}/cancel - 결재 회수
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { DraftRecord, DocumentStatus, Approver } from './types';
|
||||
import { formatDate } from '@/lib/utils/date';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
// API 응답의 결재 문서 타입
|
||||
interface ApprovalApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
};
|
||||
drafter?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
steps?: ApprovalStepApiData[];
|
||||
content?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApprovalStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
department?: { id: number; name: string };
|
||||
};
|
||||
};
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 상태 → 프론트엔드 상태 변환
|
||||
*/
|
||||
function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'draft',
|
||||
'pending': 'pending',
|
||||
'in_progress': 'inProgress',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재자 상태 변환
|
||||
*/
|
||||
function mapApproverStatus(stepStatus: string): Approver['status'] {
|
||||
const statusMap: Record<string, Approver['status']> = {
|
||||
'pending': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[stepStatus] || 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드 → 한글 변환
|
||||
*/
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
|
||||
// approval 타입 결재자만 필터링 (reference 제외)
|
||||
const approvers: Approver[] = (data.steps || [])
|
||||
.filter((step) => step.step_type === 'approval')
|
||||
.map((step) => ({
|
||||
id: String(step.approver_id),
|
||||
name: step.approver?.name || '',
|
||||
position: getPositionLabel(step.approver?.tenant_profile?.position_key),
|
||||
department: step.approver?.tenant_profile?.department?.name || '',
|
||||
status: mapApproverStatus(step.status),
|
||||
approvedAt: step.processed_at,
|
||||
}));
|
||||
|
||||
// drafter의 tenant_profile에서 직책/부서 추출
|
||||
const drafterProfile = (data.drafter as { tenant_profile?: { position_key?: string; department?: { name: string } } })?.tenant_profile;
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
documentType: data.form?.name || '',
|
||||
documentTypeCode: data.form?.code || 'proposal',
|
||||
title: data.title,
|
||||
draftDate: formatDate(data.created_at),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterPosition: getPositionLabel(drafterProfile?.position_key),
|
||||
drafterDepartment: drafterProfile?.department?.name || '',
|
||||
approvers,
|
||||
status: mapApiStatus(data.status),
|
||||
content: data.content,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
const DRAFT_STATUS_MAP: Record<string, string> = {
|
||||
'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getDrafts(params?: {
|
||||
page?: number; per_page?: number; search?: string; status?: string;
|
||||
sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
}): Promise<{ data: DraftRecord[]; total: number; lastPage: number; __authError?: boolean }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApprovalApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/drafts', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? (DRAFT_STATUS_MAP[params.status] || params.status) : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
}),
|
||||
errorMessage: '기안함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDraftsSummary(): Promise<DraftsSummary | null> {
|
||||
const result = await executeServerAction<DraftsSummary>({
|
||||
url: buildApiUrl('/api/v1/approvals/drafts/summary'),
|
||||
errorMessage: '기안함 현황 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function getDraftById(id: string): Promise<DraftRecord | null> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
transform: (data: ApprovalApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '결재 문서 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function deleteDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '결재 문서 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await deleteDraft(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 삭제에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function submitDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/submit`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 상신에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await submitDraft(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 상신에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function cancelDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/cancel`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 회수에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -1,754 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Send,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getDrafts,
|
||||
getDraftsSummary,
|
||||
getDraftById,
|
||||
deleteDraft,
|
||||
deleteDrafts,
|
||||
submitDraft,
|
||||
submitDrafts,
|
||||
} from './actions';
|
||||
import { sendApprovalNotification } from '@/lib/actions/fcm';
|
||||
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 {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
} 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,
|
||||
DocumentStatus as ModalDocumentStatus,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
DraftRecord,
|
||||
Approver,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
DOCUMENT_STATUS_LABELS,
|
||||
DOCUMENT_STATUS_COLORS,
|
||||
} from './types';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export function DraftBox() {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
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');
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<DraftRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [summary, setSummary] = useState<DraftsSummary | null>(null);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<DraftRecord | null>(null);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
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 'titleAsc':
|
||||
return { sort_by: 'title', sort_dir: 'asc' };
|
||||
case 'titleDesc':
|
||||
return { sort_by: 'title', sort_dir: 'desc' };
|
||||
default:
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
}
|
||||
})();
|
||||
|
||||
const result = await getDrafts({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
status: filterOption !== 'all' ? filterOption : undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load drafts:', error);
|
||||
toast.error('기안함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const result = await getDraftsSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 초기 로드 및 필터 변경 시 데이터 재로드 =====
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
// ===== 검색어 변경 시 페이지 초기화 =====
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, filterOption, sortOption]);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleSubmit = useCallback(
|
||||
async (selectedItems: Set<string>) => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await submitDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedItems: Set<string>) => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleDeleteSingle = useCallback(
|
||||
async (id: string) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteDraft(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 삭제했습니다.');
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleNewDocument = useCallback(() => {
|
||||
router.push('/ko/approval/draft?mode=new');
|
||||
}, [router]);
|
||||
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendApprovalNotification();
|
||||
if (result.success) {
|
||||
toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || '알림 발송에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Notification error:', error);
|
||||
toast.error('알림 발송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 문서 클릭 핸들러 =====
|
||||
const handleDocumentClick = useCallback(
|
||||
async (item: DraftRecord) => {
|
||||
if (item.status === 'draft') {
|
||||
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
||||
} else {
|
||||
const detailData = await getDraftById(item.id);
|
||||
if (detailData) {
|
||||
setSelectedDocument(detailData);
|
||||
} else {
|
||||
setSelectedDocument(item);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleModalEdit = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalCopy = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalSubmit = useCallback(async () => {
|
||||
if (!selectedDocument) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await submitDraft(selectedDocument.id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 상신했습니다.');
|
||||
setIsModalOpen(false);
|
||||
setSelectedDocument(null);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [selectedDocument, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 타입 판별 =====
|
||||
const getDocumentType = (item: DraftRecord): DocumentType => {
|
||||
if (item.documentTypeCode) {
|
||||
if (item.documentTypeCode === 'expenseEstimate') return 'expenseEstimate';
|
||||
if (item.documentTypeCode === 'expenseReport') return 'expenseReport';
|
||||
if (item.documentTypeCode === 'proposal') return 'proposal';
|
||||
}
|
||||
if (item.documentType.includes('지출') && item.documentType.includes('예상'))
|
||||
return 'expenseEstimate';
|
||||
if (item.documentType.includes('지출')) return 'expenseReport';
|
||||
return 'proposal';
|
||||
};
|
||||
|
||||
// ===== 모달용 데이터 변환 =====
|
||||
const convertToModalData = (
|
||||
item: DraftRecord
|
||||
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item);
|
||||
const content = item.content || {};
|
||||
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: item.drafterPosition || '',
|
||||
department: item.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = item.approvers.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
department: a.department,
|
||||
status: a.status,
|
||||
approvedAt: a.approvedAt,
|
||||
}));
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate': {
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
items: items.map((i, idx) => ({
|
||||
id: i.id || String(idx + 1),
|
||||
expectedPaymentDate: i.expectedPaymentDate || '',
|
||||
category: i.category || '',
|
||||
amount: i.amount || 0,
|
||||
vendor: i.vendor || '',
|
||||
account: i.memo || '',
|
||||
})),
|
||||
totalExpense: (content.totalExpense as number) || 0,
|
||||
accountBalance: (content.accountBalance as number) || 0,
|
||||
finalDifference: (content.finalDifference as number) || 0,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
case 'expenseReport': {
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
requestDate: (content.requestDate as string) || item.draftDate,
|
||||
paymentDate: (content.paymentDate as string) || item.draftDate,
|
||||
items: items.map((i, idx) => ({
|
||||
id: i.id || String(idx + 1),
|
||||
no: idx + 1,
|
||||
description: i.description || '',
|
||||
amount: i.amount || 0,
|
||||
note: i.note || '',
|
||||
})),
|
||||
cardInfo: (content.cardId as string) || '',
|
||||
totalAmount: (content.totalAmount as number) || 0,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const files =
|
||||
(content.files as Array<{ id: number; name: string; url?: string }>) || [];
|
||||
const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`);
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
vendor: (content.vendor as string) || '',
|
||||
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
|
||||
title: (content.title as string) || item.title,
|
||||
description: (content.description as string) || '',
|
||||
reason: (content.reason as string) || '',
|
||||
estimatedCost: (content.estimatedCost as number) || 0,
|
||||
attachments: attachmentUrls,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 결재자 텍스트 포맷 =====
|
||||
const formatApprovers = (approvers: Approver[]): string => {
|
||||
if (approvers.length === 0) return '-';
|
||||
if (approvers.length === 1) return approvers[0].name;
|
||||
return `${approvers[0].name} 외 ${approvers.length - 1}명`;
|
||||
};
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const draftBoxConfig: UniversalListConfig<DraftRecord> = useMemo(
|
||||
() => ({
|
||||
title: '기안함',
|
||||
description: '작성한 결재 문서를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/approval/draft',
|
||||
|
||||
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: '문서번호', copyable: true },
|
||||
{ key: 'documentType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'approvers', label: '결재자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
],
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
createButton: {
|
||||
label: '문서 작성',
|
||||
icon: Plus,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
|
||||
searchPlaceholder: '문서번호, 제목, 기안자 검색...',
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
status: filterOption,
|
||||
sort: sortOption,
|
||||
},
|
||||
filterTitle: '기안함 필터',
|
||||
|
||||
computeStats: () => {
|
||||
const inProgressCount = summary ? summary.pending : 0;
|
||||
const approvedCount = summary?.approved ?? 0;
|
||||
const rejectedCount = summary?.rejected ?? 0;
|
||||
const draftCount = summary?.draft ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: '진행',
|
||||
value: `${inProgressCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '완료',
|
||||
value: `${approvedCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '반려',
|
||||
value: `${rejectedCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '임시 저장',
|
||||
value: `${draftCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-gray-500',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// headerActions: () => (
|
||||
// <div className="ml-auto flex gap-2">
|
||||
// <Button variant="outline" onClick={handleSendNotification}>
|
||||
// <Bell className="h-4 w-4 mr-2" />
|
||||
// 문서완료
|
||||
// </Button>
|
||||
// </div>
|
||||
// ),
|
||||
|
||||
selectionActions: ({ selectedItems, onClearSelection }) => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
handleSubmit(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
상신
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterOption}
|
||||
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] 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-[120px] 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="text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[250px] truncate">
|
||||
{item.title}
|
||||
</TableCell>
|
||||
<TableCell>{formatApprovers(item.approvers)}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_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={
|
||||
<>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안일자" value={item.draftDate} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField
|
||||
label="결재자"
|
||||
value={item.approvers.map((a) => a.name).join(' → ') || '-'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () =>
|
||||
selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="draft"
|
||||
documentStatus={selectedDocument.status as ModalDocumentStatus}
|
||||
onEdit={handleModalEdit}
|
||||
onCopy={handleModalCopy}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
startDate,
|
||||
endDate,
|
||||
handleNewDocument,
|
||||
summary,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleSendNotification,
|
||||
filterOption,
|
||||
sortOption,
|
||||
handleDocumentClick,
|
||||
handleDeleteSingle,
|
||||
formatApprovers,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
handleModalEdit,
|
||||
handleModalCopy,
|
||||
handleModalSubmit,
|
||||
]
|
||||
);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
||||
if (filters.status) {
|
||||
setFilterOption(filters.status as FilterOption);
|
||||
}
|
||||
if (filters.sort) {
|
||||
setSortOption(filters.sort as SortOption);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UniversalListPage<DraftRecord>
|
||||
config={draftBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 기안함 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 문서 상태 =====
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'draft', label: '임시저장' },
|
||||
{ value: 'pending', label: '결재대기' },
|
||||
{ value: 'inProgress', label: '진행중' },
|
||||
{ value: 'approved', label: '완료' },
|
||||
{ value: 'rejected', label: '반려' },
|
||||
];
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
export type SortOption = 'latest' | 'oldest' | 'titleAsc' | 'titleDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'titleAsc', label: '제목 오름차순' },
|
||||
{ value: 'titleDesc', label: '제목 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 결재자 정보 =====
|
||||
export interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
// ===== 기안 문서 레코드 =====
|
||||
export interface DraftRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
documentType: string; // 문서제목 (양식명)
|
||||
documentTypeCode: string; // 문서유형 코드 (proposal, expenseReport, expenseEstimate)
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일자
|
||||
drafter: string; // 기안자
|
||||
drafterPosition?: string; // 기안자 직책
|
||||
drafterDepartment?: string;// 기안자 부서
|
||||
approvers: Approver[]; // 결재자 목록 (최대 3명)
|
||||
status: DocumentStatus; // 문서상태
|
||||
content?: Record<string, unknown>; // 문서 내용 (JSON)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
draft: '임시저장',
|
||||
pending: '결재대기',
|
||||
inProgress: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
|
||||
none: 'bg-gray-100 text-gray-600',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
// ===== 기안함 현황 통계 =====
|
||||
export interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* 참조함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/reference - 참조함 목록 조회
|
||||
* - POST /api/v1/approvals/{id}/read - 열람 처리
|
||||
* - POST /api/v1/approvals/{id}/unread - 미열람 처리
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface ReferenceApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: ReferenceStepApiData[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ReferenceStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
||||
const referenceStep = data.steps?.find(s => s.step_type === 'reference');
|
||||
const isRead = referenceStep?.is_read ?? false;
|
||||
const readAt = referenceStep?.read_at;
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterDepartment: data.drafter?.department?.name || '',
|
||||
drafterPosition: data.drafter?.position || '',
|
||||
documentStatus: mapApiStatus(data.status),
|
||||
readStatus: isRead ? 'read' : 'unread',
|
||||
readAt: readAt ? readAt.replace('T', ' ').substring(0, 16) : undefined,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getReferences(params?: {
|
||||
page?: number; per_page?: number; search?: string; is_read?: boolean;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
}): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<ReferenceApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/reference', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
is_read: params?.is_read !== undefined ? (params.is_read ? '1' : '0') : undefined,
|
||||
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
}),
|
||||
errorMessage: '참조 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReferenceSummary(): Promise<{ all: number; read: number; unread: number } | null> {
|
||||
try {
|
||||
const allResult = await getReferences({ per_page: 1 });
|
||||
const readResult = await getReferences({ per_page: 1, is_read: true });
|
||||
const unreadResult = await getReferences({ per_page: 1, is_read: false });
|
||||
return { all: allResult.total, read: readResult.total, unread: unreadResult.total };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAsRead(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/read`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsUnread(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/unread`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '미열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsReadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await markAsRead(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 열람 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function markAsUnreadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await markAsUnread(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 미열람 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
Files,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getReferences,
|
||||
getReferenceSummary,
|
||||
markAsReadBulk,
|
||||
markAsUnreadBulk,
|
||||
} from './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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
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 } from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
ReferenceTabType,
|
||||
ReferenceRecord,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
ApprovalType,
|
||||
} from './types';
|
||||
import {
|
||||
REFERENCE_TAB_LABELS,
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
APPROVAL_TYPE_LABELS,
|
||||
READ_STATUS_LABELS,
|
||||
READ_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface ReferenceSummary {
|
||||
all: number;
|
||||
read: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export function ReferenceBox() {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [activeTab, setActiveTab] = useState<ReferenceTabType>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);
|
||||
const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ReferenceRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [summary, setSummary] = useState<ReferenceSummary | null>(null);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
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' };
|
||||
}
|
||||
})();
|
||||
|
||||
// 탭에 따른 is_read 파라미터
|
||||
const isReadParam = activeTab === 'all' ? undefined : activeTab === 'read';
|
||||
|
||||
const result = await getReferences({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
is_read: isReadParam,
|
||||
approval_type: filterOption !== 'all' ? filterOption : undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load references:', error);
|
||||
toast.error('참조함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const result = await getReferenceSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 초기 로드 =====
|
||||
// 마운트 시 1회만 실행 (summary 로드)
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
|
||||
}, []);
|
||||
|
||||
// ===== 데이터 로드 (의존성 명시적 관리) =====
|
||||
// currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
}, [currentPage, searchQuery, filterOption, sortOption, activeTab]);
|
||||
|
||||
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
||||
// ref로 이전 값 추적하여 불필요한 상태 변경 방지 (무한 루프 방지)
|
||||
const prevSearchRef = useRef(searchQuery);
|
||||
const prevFilterRef = useRef(filterOption);
|
||||
const prevSortRef = useRef(sortOption);
|
||||
const prevTabRef = useRef(activeTab);
|
||||
|
||||
useEffect(() => {
|
||||
const searchChanged = prevSearchRef.current !== searchQuery;
|
||||
const filterChanged = prevFilterRef.current !== filterOption;
|
||||
const sortChanged = prevSortRef.current !== sortOption;
|
||||
const tabChanged = prevTabRef.current !== activeTab;
|
||||
|
||||
if (searchChanged || filterChanged || sortChanged || tabChanged) {
|
||||
// 페이지가 1이 아닐 때만 리셋 (불필요한 상태 변경 방지)
|
||||
if (currentPage !== 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
prevSearchRef.current = searchQuery;
|
||||
prevFilterRef.current = filterOption;
|
||||
prevSortRef.current = sortOption;
|
||||
prevTabRef.current = activeTab;
|
||||
}
|
||||
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
|
||||
|
||||
// ===== 탭 변경 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as ReferenceTabType);
|
||||
setSelectedItems(new Set());
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === data.length && data.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(data.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, data]);
|
||||
|
||||
// ===== 통계 데이터 (API summary 사용) =====
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
all: summary?.all ?? 0,
|
||||
read: summary?.read ?? 0,
|
||||
unread: summary?.unread ?? 0,
|
||||
};
|
||||
}, [summary]);
|
||||
|
||||
// ===== 열람/미열람 처리 핸들러 =====
|
||||
const handleMarkReadClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setMarkReadDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleMarkReadConfirm = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await markAsReadBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success('열람 처리 완료', {
|
||||
description: '열람 처리가 완료되었습니다.',
|
||||
});
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark read error:', error);
|
||||
toast.error('열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setMarkReadDialogOpen(false);
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
const handleMarkUnreadClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setMarkUnreadDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleMarkUnreadConfirm = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await markAsUnreadBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success('미열람 처리 완료', {
|
||||
description: '미열람 처리가 완료되었습니다.',
|
||||
});
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '미열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark unread error:', error);
|
||||
toast.error('미열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setMarkUnreadDialogOpen(false);
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||||
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// ===== 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' },
|
||||
{ label: '열람', value: `${stats.read}건`, icon: Eye, iconColor: 'text-green-500' },
|
||||
{ label: '미열람', value: `${stats.unread}건`, icon: EyeOff, iconColor: 'text-red-500' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 탭 옵션 (열람/미열람 토글 버튼 형태) =====
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: REFERENCE_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
||||
{ value: 'read', label: REFERENCE_TAB_LABELS.read, count: stats.read, color: 'green' },
|
||||
{ value: 'unread', label: REFERENCE_TAB_LABELS.unread, count: stats.unread, color: 'red' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
// 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호', copyable: true },
|
||||
{ key: 'approvalType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'drafter', label: '기안자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ 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: '참조함',
|
||||
description: '참조로 지정된 문서를 확인합니다.',
|
||||
icon: BookOpen,
|
||||
basePath: '/approval/reference',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
||||
searchFilter: (item: ReferenceRecord, 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,
|
||||
|
||||
tableHeaderActions: tableHeaderActions,
|
||||
|
||||
// 모바일 필터 설정
|
||||
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: '참조함 필터',
|
||||
|
||||
selectionActions: () => (
|
||||
<>
|
||||
<Button size="sm" variant="default" onClick={handleMarkReadClick}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
열람
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleMarkUnreadClick}>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
미열람
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle, onRowClick: _onRowClick } = 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.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</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={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</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.readAt || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{item.readStatus === 'unread' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedItems(new Set([item.id]));
|
||||
setMarkReadDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" /> 열람 처리
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedItems(new Set([item.id]));
|
||||
setMarkUnreadDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<EyeOff className="w-4 h-4 mr-2" /> 미열람 처리
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () => (
|
||||
<>
|
||||
{/* 열람 처리 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={markReadDialogOpen}
|
||||
onOpenChange={setMarkReadDialogOpen}
|
||||
onConfirm={handleMarkReadConfirm}
|
||||
title="열람 처리"
|
||||
description={`정말 ${selectedItems.size}건을 열람 처리하시겠습니까?`}
|
||||
/>
|
||||
|
||||
{/* 미열람 처리 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={markUnreadDialogOpen}
|
||||
onOpenChange={setMarkUnreadDialogOpen}
|
||||
onConfirm={handleMarkUnreadConfirm}
|
||||
title="미열람 처리"
|
||||
description={`정말 ${selectedItems.size}건을 미열람 처리하시겠습니까?`}
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="reference"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
tabs,
|
||||
activeTab,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
tableHeaderActions,
|
||||
handleMarkReadClick,
|
||||
handleMarkUnreadClick,
|
||||
handleDocumentClick,
|
||||
markReadDialogOpen,
|
||||
markUnreadDialogOpen,
|
||||
selectedItems.size,
|
||||
handleMarkReadConfirm,
|
||||
handleMarkUnreadConfirm,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
getDocumentType,
|
||||
convertToModalData,
|
||||
]);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
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<ReferenceRecord>
|
||||
config={referenceBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item) => item.id,
|
||||
}}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* 참조함 타입 정의
|
||||
* 열람 상태 기반 탭: 전체, 열람, 미열람
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type ReferenceTabType = 'all' | 'read' | 'unread';
|
||||
|
||||
// 열람 상태
|
||||
export type ReadStatus = 'read' | 'unread';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
|
||||
// 문서 상태
|
||||
export type DocumentStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'draftDateAsc' | 'draftDateDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'draftDateAsc', label: '기안일 오름차순' },
|
||||
{ value: 'draftDateDesc', label: '기안일 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 참조 문서 레코드 =====
|
||||
export interface ReferenceRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 문서유형
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일시
|
||||
drafter: string; // 기안자
|
||||
drafterDepartment: string; // 기안자 부서
|
||||
drafterPosition: string; // 기안자 직급
|
||||
documentStatus: DocumentStatus; // 문서 상태 (진행중, 완료, 반려)
|
||||
readStatus: ReadStatus; // 열람 상태
|
||||
readAt?: string; // 열람일시
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const REFERENCE_TAB_LABELS: Record<ReferenceTabType, string> = {
|
||||
all: '전체',
|
||||
read: '열람',
|
||||
unread: '미열람',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
pending: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const READ_STATUS_LABELS: Record<ReadStatus, string> = {
|
||||
read: '열람',
|
||||
unread: '미열람',
|
||||
};
|
||||
|
||||
export const READ_STATUS_COLORS: Record<ReadStatus, string> = {
|
||||
read: 'bg-gray-100 text-gray-800',
|
||||
unread: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
@@ -1,524 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Save, Pencil, X } from 'lucide-react';
|
||||
import type { SalaryDetail, PaymentStatus } from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
|
||||
interface AllowanceEdits {
|
||||
positionAllowance: number;
|
||||
overtimeAllowance: number;
|
||||
mealAllowance: number;
|
||||
transportAllowance: number;
|
||||
otherAllowance: number;
|
||||
}
|
||||
|
||||
interface DeductionEdits {
|
||||
nationalPension: number;
|
||||
healthInsurance: number;
|
||||
longTermCare: number;
|
||||
employmentInsurance: number;
|
||||
incomeTax: number;
|
||||
localIncomeTax: number;
|
||||
otherDeduction: number;
|
||||
}
|
||||
|
||||
interface SalaryDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
salaryDetail: SalaryDetail | null;
|
||||
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>, deductionDetails?: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
// 행 컴포넌트: 라벨 고정폭 + 값/인풋 오른쪽 정렬
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
isEditing,
|
||||
editValue,
|
||||
onChange,
|
||||
color,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isEditing?: boolean;
|
||||
editValue?: number;
|
||||
onChange?: (value: number) => void;
|
||||
color?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const editing = isEditing && onChange !== undefined;
|
||||
|
||||
return (
|
||||
<div className={editing ? 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2' : 'flex items-center gap-2'}>
|
||||
<span className={editing ? 'text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0' : 'text-muted-foreground whitespace-nowrap w-20 sm:w-24 shrink-0'}>{label}</span>
|
||||
<div className="flex-1 text-right">
|
||||
{editing ? (
|
||||
<CurrencyInput
|
||||
value={editValue ?? 0}
|
||||
onChange={(v) => onChange!(v ?? 0)}
|
||||
className="w-full h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={color}>{prefix}{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalaryDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
salaryDetail,
|
||||
onSave,
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 0,
|
||||
transportAllowance: 0,
|
||||
otherAllowance: 0,
|
||||
});
|
||||
const [editedDeductions, setEditedDeductions] = useState<DeductionEdits>({
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그가 열릴 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (salaryDetail) {
|
||||
setEditedStatus(salaryDetail.status);
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [salaryDetail]);
|
||||
|
||||
// 변경 사항 확인
|
||||
const checkForChanges = useCallback(() => {
|
||||
if (!salaryDetail) return false;
|
||||
|
||||
const statusChanged = editedStatus !== salaryDetail.status;
|
||||
const allowancesChanged =
|
||||
editedAllowances.positionAllowance !== salaryDetail.allowances.positionAllowance ||
|
||||
editedAllowances.overtimeAllowance !== salaryDetail.allowances.overtimeAllowance ||
|
||||
editedAllowances.mealAllowance !== salaryDetail.allowances.mealAllowance ||
|
||||
editedAllowances.transportAllowance !== salaryDetail.allowances.transportAllowance ||
|
||||
editedAllowances.otherAllowance !== salaryDetail.allowances.otherAllowance;
|
||||
const deductionsChanged =
|
||||
editedDeductions.nationalPension !== salaryDetail.deductions.nationalPension ||
|
||||
editedDeductions.healthInsurance !== salaryDetail.deductions.healthInsurance ||
|
||||
editedDeductions.longTermCare !== salaryDetail.deductions.longTermCare ||
|
||||
editedDeductions.employmentInsurance !== salaryDetail.deductions.employmentInsurance ||
|
||||
editedDeductions.incomeTax !== salaryDetail.deductions.incomeTax ||
|
||||
editedDeductions.localIncomeTax !== salaryDetail.deductions.localIncomeTax ||
|
||||
editedDeductions.otherDeduction !== salaryDetail.deductions.otherDeduction;
|
||||
|
||||
return statusChanged || allowancesChanged || deductionsChanged;
|
||||
}, [salaryDetail, editedStatus, editedAllowances, editedDeductions]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(checkForChanges());
|
||||
}, [checkForChanges]);
|
||||
|
||||
if (!salaryDetail) return null;
|
||||
|
||||
const handleAllowanceChange = (field: keyof AllowanceEdits, value: number) => {
|
||||
setEditedAllowances(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleDeductionChange = (field: keyof DeductionEdits, value: number) => {
|
||||
setEditedDeductions(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 수당 합계 계산
|
||||
const calculateTotalAllowance = () => {
|
||||
return Object.values(editedAllowances).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 공제 합계 계산
|
||||
const calculateTotalDeduction = () => {
|
||||
return Object.values(editedDeductions).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 실지급액 계산
|
||||
const calculateNetPayment = () => {
|
||||
return salaryDetail.baseSalary + calculateTotalAllowance() - calculateTotalDeduction();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave && salaryDetail) {
|
||||
const allowanceDetails = {
|
||||
position_allowance: editedAllowances.positionAllowance,
|
||||
overtime_allowance: editedAllowances.overtimeAllowance,
|
||||
meal_allowance: editedAllowances.mealAllowance,
|
||||
transport_allowance: editedAllowances.transportAllowance,
|
||||
other_allowance: editedAllowances.otherAllowance,
|
||||
};
|
||||
|
||||
const deductionDetails = {
|
||||
national_pension: editedDeductions.nationalPension,
|
||||
health_insurance: editedDeductions.healthInsurance,
|
||||
long_term_care: editedDeductions.longTermCare,
|
||||
employment_insurance: editedDeductions.employmentInsurance,
|
||||
income_tax: editedDeductions.incomeTax,
|
||||
local_income_tax: editedDeductions.localIncomeTax,
|
||||
other_deduction: editedDeductions.otherDeduction,
|
||||
};
|
||||
|
||||
const updatedDetail: SalaryDetail = {
|
||||
...salaryDetail,
|
||||
status: editedStatus,
|
||||
allowances: editedAllowances,
|
||||
deductions: editedDeductions,
|
||||
totalAllowance: calculateTotalAllowance(),
|
||||
totalDeduction: calculateTotalDeduction(),
|
||||
netPayment: calculateNetPayment(),
|
||||
};
|
||||
|
||||
onSave(updatedDetail, allowanceDetails, deductionDetails);
|
||||
}
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
if (isEditing) {
|
||||
// 편집 취소 - 원래 값으로 복원
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<span className="truncate">{salaryDetail.employeeName} 급여</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant={isEditing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
급여 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => setEditedStatus(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] w-auto">
|
||||
<SelectValue>
|
||||
<Badge className={PAYMENT_STATUS_COLORS[editedStatus]}>
|
||||
{PAYMENT_STATUS_LABELS[editedStatus]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.scheduled}>
|
||||
{PAYMENT_STATUS_LABELS.scheduled}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.completed}>
|
||||
{PAYMENT_STATUS_LABELS.completed}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{salaryDetail.employeeId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{salaryDetail.employeeName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">{salaryDetail.department}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{salaryDetail.rank}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">{salaryDetail.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급월</span>
|
||||
<p className="font-medium">{salaryDetail.year}년 {salaryDetail.month}월</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급일</span>
|
||||
<p className="font-medium">{salaryDetail.paymentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 급여 항목 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="본봉"
|
||||
value={`${formatCurrency(salaryDetail.baseSalary)}원`}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="직책수당"
|
||||
value={`${formatCurrency(editedAllowances.positionAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="초과근무수당"
|
||||
value={`${formatCurrency(editedAllowances.overtimeAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="식대"
|
||||
value={`${formatCurrency(editedAllowances.mealAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="교통비"
|
||||
value={`${formatCurrency(editedAllowances.transportAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타수당"
|
||||
value={`${formatCurrency(editedAllowances.otherAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="국민연금"
|
||||
value={`${formatCurrency(editedDeductions.nationalPension)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="건강보험"
|
||||
value={`${formatCurrency(editedDeductions.healthInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="장기요양보험"
|
||||
value={`${formatCurrency(editedDeductions.longTermCare)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="고용보험"
|
||||
value={`${formatCurrency(editedDeductions.employmentInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="소득세"
|
||||
value={`${formatCurrency(editedDeductions.incomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="지방소득세"
|
||||
value={`${formatCurrency(editedDeductions.localIncomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타공제"
|
||||
value={`${formatCurrency(editedDeductions.otherDeduction)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(calculateTotalDeduction())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지급 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(salaryDetail.baseSalary + calculateTotalAllowance())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(calculateTotalDeduction())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(calculateNetPayment())}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { Save, Search, UserPlus } from 'lucide-react';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
import { formatCurrency } from './types';
|
||||
|
||||
// ===== 기본값 상수 =====
|
||||
const DEFAULT_ALLOWANCES = {
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DEDUCTIONS = {
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
|
||||
// 기본급 기준 4대보험 + 세금 자동 계산
|
||||
function calculateDefaultDeductions(baseSalary: number) {
|
||||
const nationalPension = Math.round(baseSalary * 0.045); // 국민연금 4.5%
|
||||
const healthInsurance = Math.round(baseSalary * 0.03545); // 건강보험 3.545%
|
||||
const longTermCare = Math.round(healthInsurance * 0.1281); // 장기요양 12.81% of 건강보험
|
||||
const employmentInsurance = Math.round(baseSalary * 0.009); // 고용보험 0.9%
|
||||
const totalIncome = baseSalary + DEFAULT_ALLOWANCES.mealAllowance + DEFAULT_ALLOWANCES.transportAllowance;
|
||||
const incomeTax = Math.round(totalIncome * 0.05); // 소득세 (간이세액 근사)
|
||||
const localIncomeTax = Math.round(incomeTax * 0.1); // 지방소득세 10% of 소득세
|
||||
|
||||
return {
|
||||
nationalPension,
|
||||
healthInsurance,
|
||||
longTermCare,
|
||||
employmentInsurance,
|
||||
incomeTax,
|
||||
localIncomeTax,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 행 컴포넌트 =====
|
||||
function EditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
prefix: _prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? 0)}
|
||||
className="w-full h-7 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
interface SalaryRegistrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
export function SalaryRegistrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: SalaryRegistrationDialogProps) {
|
||||
// 사원 선택
|
||||
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
|
||||
const searchOpenRef = useRef(false);
|
||||
|
||||
// 급여 기본 정보
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [baseSalary, setBaseSalary] = useState(0);
|
||||
|
||||
// 수당
|
||||
const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES });
|
||||
|
||||
// 공제
|
||||
const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS });
|
||||
|
||||
// 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연)
|
||||
const handleSearchOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
searchOpenRef.current = true;
|
||||
} else {
|
||||
setTimeout(() => { searchOpenRef.current = false; }, 300);
|
||||
}
|
||||
setEmployeeSearchOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
// 사원 선택 시 기본값 세팅
|
||||
const handleSelectEmployee = useCallback((employee: Employee) => {
|
||||
setSelectedEmployee(employee);
|
||||
handleSearchOpenChange(false);
|
||||
|
||||
// 기본급 세팅 (연봉 / 12)
|
||||
const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0;
|
||||
setBaseSalary(monthlySalary);
|
||||
|
||||
// 기본 공제 자동 계산
|
||||
if (monthlySalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(monthlySalary));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 사원 검색 fetch
|
||||
const handleFetchEmployees = useCallback(async (query: string) => {
|
||||
const result = await getEmployees({
|
||||
q: query || undefined,
|
||||
status: 'active',
|
||||
per_page: 50,
|
||||
});
|
||||
return result.data || [];
|
||||
}, []);
|
||||
|
||||
// 검색어 유효성
|
||||
const isValidSearch = useCallback((query: string) => {
|
||||
if (!query || !query.trim()) return false;
|
||||
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
||||
}, []);
|
||||
|
||||
// 수당 변경
|
||||
const handleAllowanceChange = useCallback((field: string, value: number) => {
|
||||
setAllowances(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 공제 변경
|
||||
const handleDeductionChange = useCallback((field: string, value: number) => {
|
||||
setDeductions(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 합계 계산
|
||||
const totalAllowance = useMemo(() =>
|
||||
Object.values(allowances).reduce((sum, v) => sum + v, 0),
|
||||
[allowances]
|
||||
);
|
||||
|
||||
const totalDeduction = useMemo(() =>
|
||||
Object.values(deductions).reduce((sum, v) => sum + v, 0),
|
||||
[deductions]
|
||||
);
|
||||
|
||||
const netPayment = useMemo(() =>
|
||||
baseSalary + totalAllowance - totalDeduction,
|
||||
[baseSalary, totalAllowance, totalDeduction]
|
||||
);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0;
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
if (!selectedEmployee || !canSave) return;
|
||||
|
||||
const dept = selectedEmployee.departmentPositions?.[0];
|
||||
|
||||
onSave({
|
||||
employeeId: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
|
||||
employeeName: selectedEmployee.name,
|
||||
department: dept?.departmentName || '-',
|
||||
position: dept?.positionName || '-',
|
||||
rank: selectedEmployee.rank || '-',
|
||||
year,
|
||||
month,
|
||||
baseSalary,
|
||||
paymentDate,
|
||||
allowances: {
|
||||
position_allowance: allowances.positionAllowance,
|
||||
overtime_allowance: allowances.overtimeAllowance,
|
||||
meal_allowance: allowances.mealAllowance,
|
||||
transport_allowance: allowances.transportAllowance,
|
||||
other_allowance: allowances.otherAllowance,
|
||||
},
|
||||
deductions: {
|
||||
national_pension: deductions.nationalPension,
|
||||
health_insurance: deductions.healthInsurance,
|
||||
long_term_care: deductions.longTermCare,
|
||||
employment_insurance: deductions.employmentInsurance,
|
||||
income_tax: deductions.incomeTax,
|
||||
local_income_tax: deductions.localIncomeTax,
|
||||
other_deduction: deductions.otherDeduction,
|
||||
},
|
||||
});
|
||||
}, [selectedEmployee, canSave, year, month, baseSalary, paymentDate, allowances, deductions, onSave]);
|
||||
|
||||
// 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && searchOpenRef.current) return;
|
||||
if (!isOpen) {
|
||||
setSelectedEmployee(null);
|
||||
setBaseSalary(0);
|
||||
setPaymentDate('');
|
||||
setAllowances({ ...DEFAULT_ALLOWANCES });
|
||||
setDeductions({ ...DEFAULT_DEDUCTIONS });
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// 년도 옵션
|
||||
const yearOptions = useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i);
|
||||
}, []);
|
||||
|
||||
// 월 옵션
|
||||
const monthOptions = useMemo(() =>
|
||||
Array.from({ length: 12 }, (_, i) => i + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
급여 등록
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 사원 선택 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
|
||||
{/* 사원 선택 버튼 */}
|
||||
{!selectedEmployee ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 border-dashed"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
사원 검색 (클릭하여 선택)
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{selectedEmployee.employeeCode || selectedEmployee.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{selectedEmployee.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.departmentName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{selectedEmployee.rank || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.positionName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
다른 사원 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지급월 / 지급일 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">년도</span>
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}년</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">월</span>
|
||||
<Select value={String(month)} onValueChange={(v) => setMonth(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map(m => (
|
||||
<SelectItem key={m} value={String(m)}>{m}월</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">지급일</span>
|
||||
<DatePicker
|
||||
value={paymentDate}
|
||||
onChange={setPaymentDate}
|
||||
placeholder="지급일 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본급 */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">기본급 (월)</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => {
|
||||
const newSalary = v ?? 0;
|
||||
setBaseSalary(newSalary);
|
||||
if (newSalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(newSalary));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{selectedEmployee?.salary && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
연봉 {formatCurrency(selectedEmployee.salary)}원 기준
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수당 / 공제 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="직책수당"
|
||||
value={allowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="초과근무수당"
|
||||
value={allowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="식대"
|
||||
value={allowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="교통비"
|
||||
value={allowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타수당"
|
||||
value={allowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(totalAllowance)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="국민연금"
|
||||
value={deductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="건강보험"
|
||||
value={deductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="장기요양보험"
|
||||
value={deductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="고용보험"
|
||||
value={deductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<EditableRow
|
||||
label="소득세"
|
||||
value={deductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="지방소득세"
|
||||
value={deductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타공제"
|
||||
value={deductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(totalDeduction)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(baseSalary + totalAllowance)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(totalDeduction)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(netPayment)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 검색 모달 */}
|
||||
<SearchableSelectionModal<Employee>
|
||||
open={employeeSearchOpen}
|
||||
onOpenChange={handleSearchOpenChange}
|
||||
title="사원 검색"
|
||||
searchPlaceholder="사원명, 사원코드 검색..."
|
||||
fetchData={handleFetchEmployees}
|
||||
keyExtractor={(emp) => emp.id}
|
||||
validateSearch={isValidSearch}
|
||||
invalidSearchMessage="한글, 영문 또는 숫자 1자 이상 입력하세요"
|
||||
emptyQueryMessage="사원명 또는 사원코드를 입력하세요"
|
||||
loadingMessage="사원 검색 중..."
|
||||
dialogClassName="sm:max-w-[500px]"
|
||||
infoText={(items, isLoading) =>
|
||||
!isLoading ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}명
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelectEmployee}
|
||||
renderItem={(employee) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{employee.name}</span>
|
||||
{employee.employeeCode && (
|
||||
<span className="text-xs text-gray-400 ml-2">({employee.employeeCode})</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.rank && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{employee.rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.departmentPositions.length > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{employee.departmentPositions
|
||||
.map(dp => `${dp.departmentName} / ${dp.positionName}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{employee.salary && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
연봉: {Number(employee.salary).toLocaleString()}원
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
interface SalaryApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: string;
|
||||
total_allowance: string;
|
||||
total_overtime: string;
|
||||
total_bonus: string;
|
||||
total_deduction: string;
|
||||
net_payment: string;
|
||||
allowance_details: Record<string, number> | null;
|
||||
deduction_details: Record<string, number> | null;
|
||||
payment_date: string | null;
|
||||
status: 'scheduled' | 'completed';
|
||||
employee?: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
employee_profile?: {
|
||||
id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
job_title_key: string | null;
|
||||
position_label: string | null;
|
||||
job_title_label: string | null;
|
||||
rank: string | null;
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SalaryPaginationData {
|
||||
data: SalaryApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface StatisticsApiData {
|
||||
total_net_payment: number;
|
||||
total_base_salary: number;
|
||||
total_allowance: number;
|
||||
total_overtime: number;
|
||||
total_bonus: number;
|
||||
total_deduction: number;
|
||||
count: number;
|
||||
scheduled_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (목록용)
|
||||
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
|
||||
const profile = apiData.employee_profile;
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowance: parseFloat(apiData.total_allowance),
|
||||
overtime: parseFloat(apiData.total_overtime),
|
||||
bonus: parseFloat(apiData.total_bonus),
|
||||
deduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (상세용)
|
||||
function transformApiToDetail(apiData: SalaryApiData): SalaryDetail {
|
||||
const allowanceDetails = apiData.allowance_details || {};
|
||||
const deductionDetails = apiData.deduction_details || {};
|
||||
const profile = apiData.employee_profile;
|
||||
|
||||
return {
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowances: {
|
||||
positionAllowance: allowanceDetails.position_allowance || 0,
|
||||
overtimeAllowance: allowanceDetails.overtime_allowance || 0,
|
||||
mealAllowance: allowanceDetails.meal_allowance || 0,
|
||||
transportAllowance: allowanceDetails.transport_allowance || 0,
|
||||
otherAllowance: allowanceDetails.other_allowance || 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: deductionDetails.national_pension || 0,
|
||||
healthInsurance: deductionDetails.health_insurance || 0,
|
||||
longTermCare: deductionDetails.long_term_care || 0,
|
||||
employmentInsurance: deductionDetails.employment_insurance || 0,
|
||||
incomeTax: deductionDetails.income_tax || 0,
|
||||
localIncomeTax: deductionDetails.local_income_tax || 0,
|
||||
otherDeduction: deductionDetails.other_deduction || 0,
|
||||
},
|
||||
totalAllowance: parseFloat(apiData.total_allowance),
|
||||
totalDeduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 목록 조회 =====
|
||||
export async function getSalaries(params?: {
|
||||
search?: string; year?: number; month?: number; status?: string;
|
||||
employee_id?: number; start_date?: string; end_date?: string;
|
||||
page?: number; per_page?: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: SalaryRecord[];
|
||||
pagination?: { total: number; currentPage: number; lastPage: number };
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<SalaryPaginationData>({
|
||||
url: buildApiUrl('/api/v1/salaries', {
|
||||
search: params?.search,
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
}),
|
||||
errorMessage: '급여 목록을 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
pagination: {
|
||||
total: result.data.total,
|
||||
currentPage: result.data.current_page,
|
||||
lastPage: result.data.last_page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 상세 조회 =====
|
||||
export async function getSalary(id: string): Promise<{
|
||||
success: boolean; data?: SalaryDetail; error?: string
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
transform: (data: SalaryApiData) => transformApiToDetail(data),
|
||||
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 상태 변경 =====
|
||||
export async function updateSalaryStatus(
|
||||
id: string,
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
transform: (data: SalaryApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '상태 변경에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 일괄 상태 변경 =====
|
||||
export async function bulkUpdateSalaryStatus(
|
||||
ids: string[],
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; updatedCount?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ updated_count: number }>({
|
||||
url: buildApiUrl('/api/v1/salaries/bulk-update-status'),
|
||||
method: 'POST',
|
||||
body: { ids: ids.map(id => parseInt(id, 10)), status },
|
||||
errorMessage: '일괄 상태 변경에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, updatedCount: result.data.updated_count };
|
||||
}
|
||||
|
||||
// ===== 급여 수정 =====
|
||||
export async function updateSalary(
|
||||
id: string,
|
||||
data: {
|
||||
base_salary?: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
status?: PaymentStatus;
|
||||
payment_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 수정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 등록 =====
|
||||
export async function createSalary(data: {
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
payment_date?: string;
|
||||
status?: PaymentStatus;
|
||||
}): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/salaries'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 통계 조회 =====
|
||||
export async function getSalaryStatistics(params?: {
|
||||
year?: number; month?: number; start_date?: string; end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalNetPayment: number; totalBaseSalary: number; totalAllowance: number;
|
||||
totalOvertime: number; totalBonus: number; totalDeduction: number;
|
||||
count: number; scheduledCount: number; completedCount: number;
|
||||
};
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<StatisticsApiData>({
|
||||
url: buildApiUrl('/api/v1/salaries/statistics', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '통계 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalNetPayment: result.data.total_net_payment,
|
||||
totalBaseSalary: result.data.total_base_salary,
|
||||
totalAllowance: result.data.total_allowance,
|
||||
totalOvertime: result.data.total_overtime,
|
||||
totalBonus: result.data.total_bonus,
|
||||
totalDeduction: result.data.total_deduction,
|
||||
count: result.data.count,
|
||||
scheduledCount: result.data.scheduled_count,
|
||||
completedCount: result.data.completed_count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 엑셀 내보내기 (native fetch - keep as-is) =====
|
||||
export async function exportSalaryExcel(params?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: string;
|
||||
employee_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
const url = buildApiUrl('/api/v1/salaries/export', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||
const filename = filenameMatch?.[1] || `급여명세_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||
|
||||
return { success: true, data: blob, filename };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
DollarSign,
|
||||
Check,
|
||||
Clock,
|
||||
Banknote,
|
||||
Briefcase,
|
||||
Timer,
|
||||
Gift,
|
||||
MinusCircle,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
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 {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type FilterFieldConfig,
|
||||
type FilterValues,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
||||
import { SalaryRegistrationDialog } from './SalaryRegistrationDialog';
|
||||
import {
|
||||
getSalaries,
|
||||
getSalary,
|
||||
createSalary,
|
||||
bulkUpdateSalaryStatus,
|
||||
updateSalaryStatus,
|
||||
updateSalary,
|
||||
} from './actions';
|
||||
import type {
|
||||
SalaryRecord,
|
||||
SalaryDetail,
|
||||
SortOption,
|
||||
} from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
SORT_OPTIONS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 목 데이터 (API 연동 전 테스트용) =====
|
||||
const MOCK_SALARY_RECORDS: SalaryRecord[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowance: 850000,
|
||||
overtime: 320000,
|
||||
bonus: 0,
|
||||
deduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-01',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowance: 550000,
|
||||
overtime: 0,
|
||||
bonus: 500000,
|
||||
deduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SALARY_DETAILS: Record<string, SalaryDetail> = {
|
||||
'mock-1': {
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowances: {
|
||||
positionAllowance: 300000,
|
||||
overtimeAllowance: 320000,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 157500,
|
||||
healthInsurance: 121450,
|
||||
longTermCare: 14820,
|
||||
employmentInsurance: 31500,
|
||||
incomeTax: 185230,
|
||||
localIncomeTax: 18520,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 870000,
|
||||
totalDeduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
'mock-2': {
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowances: {
|
||||
positionAllowance: 200000,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 100000,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 135000,
|
||||
healthInsurance: 104100,
|
||||
longTermCare: 12700,
|
||||
employmentInsurance: 27000,
|
||||
incomeTax: 160200,
|
||||
localIncomeTax: 16020,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 550000,
|
||||
totalDeduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export function SalaryManagement() {
|
||||
const { canExport: _canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('rank');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [selectedSalaryDetail, setSelectedSalaryDetail] = useState<SalaryDetail | null>(null);
|
||||
const [selectedSalaryId, setSelectedSalaryId] = useState<string | null>(null);
|
||||
const [registrationDialogOpen, setRegistrationDialogOpen] = useState(false);
|
||||
|
||||
// 데이터 상태
|
||||
const [salaryData, setSalaryData] = useState<SalaryRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadSalaries = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await getSalaries({
|
||||
search: searchQuery || undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
});
|
||||
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setSalaryData(result.data);
|
||||
setTotalCount(result.pagination?.total || result.data.length);
|
||||
setTotalPages(result.pagination?.lastPage || 1);
|
||||
} else {
|
||||
// API 데이터가 없으면 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('loadSalaries error:', error);
|
||||
// API 실패 시에도 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [searchQuery, startDate, endDate, currentPage, itemsPerPage]);
|
||||
|
||||
// 초기 데이터 로드 및 검색/필터 변경 시 재로드
|
||||
useEffect(() => {
|
||||
loadSalaries();
|
||||
}, [loadSalaries]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === salaryData.length && salaryData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(salaryData.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, salaryData]);
|
||||
|
||||
// ===== 지급완료 핸들러 =====
|
||||
const handleMarkCompleted = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await bulkUpdateSalaryStatus(
|
||||
Array.from(selectedItems),
|
||||
'completed'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.updatedCount || selectedItems.size}건이 지급완료 처리되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkCompleted error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedItems, loadSalaries]);
|
||||
|
||||
// ===== 지급예정 핸들러 =====
|
||||
const handleMarkScheduled = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await bulkUpdateSalaryStatus(
|
||||
Array.from(selectedItems),
|
||||
'scheduled'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.updatedCount || selectedItems.size}건이 지급예정 처리되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkScheduled error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedItems, loadSalaries]);
|
||||
|
||||
// ===== 상세보기 핸들러 =====
|
||||
const handleViewDetail = useCallback(async (record: SalaryRecord) => {
|
||||
setSelectedSalaryId(record.id);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 목 상세 데이터 사용
|
||||
if (record.id.startsWith('mock-')) {
|
||||
const mockDetail = MOCK_SALARY_DETAILS[record.id];
|
||||
if (mockDetail) {
|
||||
setSelectedSalaryDetail(mockDetail);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getSalary(record.id);
|
||||
if (result.success && result.data) {
|
||||
setSelectedSalaryDetail(result.data);
|
||||
setDetailDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || '급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleViewDetail error:', error);
|
||||
toast.error('급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 급여 상세 저장 핸들러 =====
|
||||
const handleSaveDetail = useCallback(async (
|
||||
updatedDetail: SalaryDetail,
|
||||
allowanceDetails?: Record<string, number>,
|
||||
deductionDetails?: Record<string, number>
|
||||
) => {
|
||||
if (!selectedSalaryId) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 로컬 상태만 업데이트
|
||||
if (selectedSalaryId.startsWith('mock-')) {
|
||||
setSalaryData(prev => prev.map(item =>
|
||||
item.id === selectedSalaryId
|
||||
? {
|
||||
...item,
|
||||
status: updatedDetail.status,
|
||||
allowance: updatedDetail.totalAllowance,
|
||||
deduction: updatedDetail.totalDeduction,
|
||||
netPayment: updatedDetail.netPayment,
|
||||
}
|
||||
: item
|
||||
));
|
||||
toast.success('급여 정보가 저장되었습니다. (목 데이터)');
|
||||
setDetailDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 수당/공제 정보가 변경된 경우 updateSalary API 호출
|
||||
if (allowanceDetails || deductionDetails) {
|
||||
const result = await updateSalary(selectedSalaryId, {
|
||||
allowance_details: allowanceDetails,
|
||||
deduction_details: deductionDetails,
|
||||
status: updatedDetail.status,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 상태만 변경된 경우 기존 API 호출
|
||||
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleSaveDetail error:', error);
|
||||
toast.error('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedSalaryId, loadSalaries]);
|
||||
|
||||
// ===== 지급항목 추가 핸들러 =====
|
||||
const handleAddPaymentItem = useCallback(() => {
|
||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 급여 등록 핸들러 =====
|
||||
const handleCreateSalary = useCallback(async (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => {
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await createSalary({
|
||||
employee_id: data.employeeId,
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
base_salary: data.baseSalary,
|
||||
allowance_details: data.allowances,
|
||||
deduction_details: data.deductions,
|
||||
payment_date: data.paymentDate || undefined,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('급여가 등록되었습니다.');
|
||||
setRegistrationDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
// API 실패 시 목 데이터로 로컬 추가
|
||||
const totalAllowance = Object.values(data.allowances).reduce((s, v) => s + v, 0);
|
||||
const totalDeduction = Object.values(data.deductions).reduce((s, v) => s + v, 0);
|
||||
const mockId = `mock-${Date.now()}`;
|
||||
const newRecord: SalaryRecord = {
|
||||
id: mockId,
|
||||
employeeId: String(data.employeeId),
|
||||
employeeName: data.employeeName,
|
||||
department: data.department,
|
||||
position: data.position,
|
||||
rank: data.rank,
|
||||
baseSalary: data.baseSalary,
|
||||
allowance: totalAllowance,
|
||||
overtime: data.allowances.overtime_allowance || 0,
|
||||
bonus: 0,
|
||||
deduction: totalDeduction,
|
||||
netPayment: data.baseSalary + totalAllowance - totalDeduction,
|
||||
paymentDate: data.paymentDate,
|
||||
status: 'scheduled',
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSalaryData(prev => [...prev, newRecord]);
|
||||
setTotalCount(prev => prev + 1);
|
||||
toast.success('급여가 등록되었습니다. (목 데이터)');
|
||||
setRegistrationDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleCreateSalary error:', error);
|
||||
toast.error('급여 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [loadSalaries]);
|
||||
|
||||
// ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) =====
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0);
|
||||
const totalBaseSalary = salaryData.reduce((sum, s) => sum + s.baseSalary, 0);
|
||||
const totalAllowance = salaryData.reduce((sum, s) => sum + s.allowance, 0);
|
||||
const totalOvertime = salaryData.reduce((sum, s) => sum + s.overtime, 0);
|
||||
const totalBonus = salaryData.reduce((sum, s) => sum + s.bonus, 0);
|
||||
const totalDeduction = salaryData.reduce((sum, s) => sum + s.deduction, 0);
|
||||
|
||||
return [
|
||||
{
|
||||
label: '총 실지급액',
|
||||
value: `${formatCurrency(totalNetPayment)}원`,
|
||||
icon: DollarSign,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '총 기본급',
|
||||
value: `${formatCurrency(totalBaseSalary)}원`,
|
||||
icon: Banknote,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '총 수당',
|
||||
value: `${formatCurrency(totalAllowance)}원`,
|
||||
icon: Briefcase,
|
||||
iconColor: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
label: '초과근무',
|
||||
value: `${formatCurrency(totalOvertime)}원`,
|
||||
icon: Timer,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: '상여',
|
||||
value: `${formatCurrency(totalBonus)}원`,
|
||||
icon: Gift,
|
||||
iconColor: 'text-pink-500',
|
||||
},
|
||||
{
|
||||
label: '총 공제',
|
||||
value: `${formatCurrency(totalDeduction)}원`,
|
||||
icon: MinusCircle,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
];
|
||||
}, [salaryData]);
|
||||
|
||||
// ===== 테이블 컬럼 (부서, 직책, 이름, 직급, 기본급, 수당, 초과근무, 상여, 공제, 실지급액, 일자, 상태, 작업) =====
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'department', label: '부서', sortable: true, copyable: true },
|
||||
{ key: 'position', label: '직책', sortable: true, copyable: true },
|
||||
{ key: 'name', label: '이름', sortable: true, copyable: true },
|
||||
{ key: 'rank', label: '직급', sortable: true, copyable: true },
|
||||
{ key: 'baseSalary', label: '기본급', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'allowance', label: '수당', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'overtime', label: '초과근무', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'bonus', label: '상여', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'deduction', label: '공제', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'netPayment', label: '실지급액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'paymentDate', label: '일자', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: Object.entries(SORT_OPTIONS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
sort: sortOption,
|
||||
}), [sortOption]);
|
||||
|
||||
const _handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'sort':
|
||||
setSortOption(value as SortOption);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const _handleFilterReset = useCallback(() => {
|
||||
setSortOption('rank');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const salaryConfig: UniversalListConfig<SalaryRecord> = useMemo(() => ({
|
||||
title: '급여관리',
|
||||
description: '직원들의 급여 현황을 관리합니다',
|
||||
icon: DollarSign,
|
||||
basePath: '/hr/salary-management',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: salaryData,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
filterConfig: filterConfig,
|
||||
initialFilters: filterValues,
|
||||
filterTitle: '급여 필터',
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
searchPlaceholder: '이름, 부서 검색...',
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
// 엑셀 다운로드 설정
|
||||
excelDownload: {
|
||||
columns: [
|
||||
{ header: '부서', key: 'department' },
|
||||
{ header: '직책', key: 'position' },
|
||||
{ header: '이름', key: 'employeeName' },
|
||||
{ header: '직급', key: 'rank' },
|
||||
{ header: '기본급', key: 'baseSalary' },
|
||||
{ header: '수당', key: 'allowance' },
|
||||
{ header: '초과근무', key: 'overtime' },
|
||||
{ header: '상여', key: 'bonus' },
|
||||
{ header: '공제', key: 'deduction' },
|
||||
{ header: '실지급액', key: 'netPayment' },
|
||||
{ header: '지급일', key: 'paymentDate' },
|
||||
{ header: '상태', key: 'status', transform: (value: unknown) => value === 'completed' ? '지급완료' : '지급예정' },
|
||||
],
|
||||
filename: '급여명세',
|
||||
sheetName: '급여',
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택 (DateRangeSelector 사용)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
selectionActions: () => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleMarkCompleted}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
지급완료
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleMarkScheduled}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
지급예정
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
headerActions: () => (
|
||||
<Button size="sm" onClick={() => setRegistrationDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
급여 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetail(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell>{item.department}</TableCell>
|
||||
<TableCell>{item.position}</TableCell>
|
||||
<TableCell className="font-medium">{item.employeeName}</TableCell>
|
||||
<TableCell>{item.rank}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.baseSalary)}원</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.allowance)}원</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.overtime)}원</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.bonus)}원</TableCell>
|
||||
<TableCell className="text-right text-red-600">-{formatCurrency(item.deduction)}원</TableCell>
|
||||
<TableCell className="text-right font-medium text-green-600">{formatCurrency(item.netPayment)}원</TableCell>
|
||||
<TableCell className="text-center">{item.paymentDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
|
||||
{PAYMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.employeeName}
|
||||
headerBadges={
|
||||
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
|
||||
{PAYMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleViewDetail(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="부서" value={item.department} />
|
||||
<InfoField label="직급" value={item.rank} />
|
||||
<InfoField label="기본급" value={`${formatCurrency(item.baseSalary)}원`} />
|
||||
<InfoField label="수당" value={`${formatCurrency(item.allowance)}원`} />
|
||||
<InfoField label="초과근무" value={`${formatCurrency(item.overtime)}원`} />
|
||||
<InfoField label="상여" value={`${formatCurrency(item.bonus)}원`} />
|
||||
<InfoField label="공제" value={`-${formatCurrency(item.deduction)}원`} />
|
||||
<InfoField label="실지급액" value={`${formatCurrency(item.netPayment)}원`} />
|
||||
<InfoField label="지급일" value={item.paymentDate} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: (_params) => (
|
||||
<>
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<SalaryRegistrationDialog
|
||||
open={registrationDialogOpen}
|
||||
onOpenChange={setRegistrationDialogOpen}
|
||||
onSave={handleCreateSalary}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
salaryData,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
filterConfig,
|
||||
filterValues,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
handleMarkCompleted,
|
||||
handleMarkScheduled,
|
||||
isActionLoading,
|
||||
handleViewDetail,
|
||||
detailDialogOpen,
|
||||
selectedSalaryDetail,
|
||||
handleSaveDetail,
|
||||
handleAddPaymentItem,
|
||||
registrationDialogOpen,
|
||||
handleCreateSalary,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UniversalListPage<SalaryRecord>
|
||||
config={salaryConfig}
|
||||
initialData={salaryData}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item) => item.id,
|
||||
}}
|
||||
onSearchChange={setSearchQuery}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 급여관리 타입 정의
|
||||
*/
|
||||
|
||||
// 급여 상태 타입
|
||||
export type PaymentStatus = 'scheduled' | 'completed';
|
||||
|
||||
// 정렬 옵션 타입
|
||||
export type SortOption = 'rank' | 'name' | 'department' | 'paymentDate';
|
||||
|
||||
// 급여 레코드 인터페이스
|
||||
export interface SalaryRecord {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseSalary: number; // 기본급
|
||||
allowance: number; // 수당
|
||||
overtime: number; // 초과근무
|
||||
bonus: number; // 상여
|
||||
deduction: number; // 공제
|
||||
netPayment: number; // 실지급액
|
||||
paymentDate: string; // 지급일
|
||||
status: PaymentStatus; // 상태
|
||||
year: number; // 년도
|
||||
month: number; // 월
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 급여 상세 정보 인터페이스
|
||||
export interface SalaryDetail {
|
||||
// 기본 정보
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
|
||||
// 급여 정보
|
||||
baseSalary: number; // 본봉
|
||||
|
||||
// 수당 내역
|
||||
allowances: {
|
||||
positionAllowance: number; // 직책수당
|
||||
overtimeAllowance: number; // 초과근무수당
|
||||
mealAllowance: number; // 식대
|
||||
transportAllowance: number; // 교통비
|
||||
otherAllowance: number; // 기타수당
|
||||
};
|
||||
|
||||
// 공제 내역
|
||||
deductions: {
|
||||
nationalPension: number; // 국민연금
|
||||
healthInsurance: number; // 건강보험
|
||||
longTermCare: number; // 장기요양보험
|
||||
employmentInsurance: number; // 고용보험
|
||||
incomeTax: number; // 소득세
|
||||
localIncomeTax: number; // 지방소득세
|
||||
otherDeduction: number; // 기타공제
|
||||
};
|
||||
|
||||
// 합계
|
||||
totalAllowance: number; // 수당 합계
|
||||
totalDeduction: number; // 공제 합계
|
||||
netPayment: number; // 실지급액
|
||||
|
||||
// 추가 정보
|
||||
paymentDate: string;
|
||||
status: PaymentStatus;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||
scheduled: '지급예정',
|
||||
completed: '지급완료',
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
export const PAYMENT_STATUS_COLORS: Record<PaymentStatus, string> = {
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 정렬 옵션 라벨
|
||||
export const SORT_OPTIONS: Record<SortOption, string> = {
|
||||
rank: '직급순',
|
||||
name: '이름순',
|
||||
department: '부서순',
|
||||
paymentDate: '지급일순',
|
||||
};
|
||||
|
||||
// 금액 포맷 유틸리티
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
@@ -1,370 +0,0 @@
|
||||
// @ts-nocheck - Legacy file, not in use
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Landmark, Save, Trash2, X, Edit, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { Account, AccountFormData, AccountStatus } from '../types';
|
||||
import {
|
||||
BANK_OPTIONS,
|
||||
BANK_LABELS,
|
||||
ACCOUNT_STATUS_OPTIONS,
|
||||
ACCOUNT_STATUS_LABELS,
|
||||
ACCOUNT_STATUS_COLORS,
|
||||
} from '../types';
|
||||
import { createBankAccount, updateBankAccount, deleteBankAccount } from '../actions';
|
||||
|
||||
interface AccountDetailProps {
|
||||
account?: Account;
|
||||
mode: 'create' | 'view' | 'edit';
|
||||
}
|
||||
|
||||
export function AccountDetail({ account, mode: initialMode }: AccountDetailProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// URL에서 mode 파라미터 확인
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode');
|
||||
if (urlMode === 'edit' && account) {
|
||||
setMode('edit');
|
||||
}
|
||||
}, [searchParams, account]);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<AccountFormData>({
|
||||
bankCode: account?.bankCode || '',
|
||||
bankName: account?.bankName || '',
|
||||
accountNumber: account?.accountNumber || '',
|
||||
accountName: account?.accountName || '',
|
||||
accountHolder: account?.accountHolder || '',
|
||||
accountPassword: '',
|
||||
status: account?.status || 'active',
|
||||
});
|
||||
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
const handleChange = (field: keyof AccountFormData, value: string) => {
|
||||
setFormData((prev: AccountFormData) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/ko/settings/accounts');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const dataToSend = {
|
||||
...formData,
|
||||
bankName: BANK_LABELS[formData.bankCode] || formData.bankCode,
|
||||
};
|
||||
|
||||
if (isCreateMode) {
|
||||
const result = await createBankAccount(dataToSend);
|
||||
if (result.success) {
|
||||
toast.success('계좌가 등록되었습니다.');
|
||||
router.push('/ko/settings/accounts');
|
||||
} else {
|
||||
toast.error(result.error || '계좌 등록에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
if (!account?.id) return;
|
||||
const result = await updateBankAccount(account.id, dataToSend);
|
||||
if (result.success) {
|
||||
toast.success('계좌가 수정되었습니다.');
|
||||
router.push('/ko/settings/accounts');
|
||||
} else {
|
||||
toast.error(result.error || '계좌 수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!account?.id) return;
|
||||
const result = await deleteBankAccount(account.id);
|
||||
if (result.success) {
|
||||
toast.success('계좌가 삭제되었습니다.');
|
||||
router.push('/ko/settings/accounts');
|
||||
} else {
|
||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isCreateMode) {
|
||||
router.push('/ko/settings/accounts');
|
||||
} else {
|
||||
setMode('view');
|
||||
// 원래 데이터로 복원
|
||||
if (account) {
|
||||
setFormData({
|
||||
bankCode: account.bankCode, bankName: account.bankName,
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
accountHolder: account.accountHolder,
|
||||
accountPassword: '',
|
||||
status: account.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
// 뷰 모드 렌더링
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="계좌 상세"
|
||||
description="계좌 정보를 관리합니다"
|
||||
icon={Landmark}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
|
||||
{ACCOUNT_STATUS_LABELS[formData.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">은행</dt>
|
||||
<dd className="text-sm mt-1">{BANK_LABELS[formData.bankCode] || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">계좌번호</dt>
|
||||
<dd className="text-sm mt-1 font-mono">{formData.accountNumber || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">예금주</dt>
|
||||
<dd className="text-sm mt-1">{formData.accountHolder || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">계좌 비밀번호</dt>
|
||||
<dd className="text-sm mt-1">****</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">계좌명</dt>
|
||||
<dd className="text-sm mt-1">{formData.accountName || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">상태</dt>
|
||||
<dd className="text-sm mt-1">
|
||||
<Badge className={ACCOUNT_STATUS_COLORS[formData.status]}>
|
||||
{ACCOUNT_STATUS_LABELS[formData.status]}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계좌 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
계좌를 정말 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 계좌의 과거 사용 내역은 보존됩니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 생성/수정 모드 렌더링
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isCreateMode ? '계좌 등록' : '계좌 수정'}
|
||||
description="계좌 정보를 관리합니다"
|
||||
icon={Landmark}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 은행 & 계좌번호 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankCode">은행</Label>
|
||||
<Select
|
||||
value={formData.bankCode}
|
||||
onValueChange={(value) => handleChange('bankCode', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="은행 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANK_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountNumber">계좌번호</Label>
|
||||
<Input
|
||||
id="accountNumber"
|
||||
value={formData.accountNumber}
|
||||
onChange={(e) => handleChange('accountNumber', e.target.value)}
|
||||
placeholder="1234-1234-1234-1234"
|
||||
disabled={isEditMode} // 수정 시 계좌번호는 변경 불가
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예금주 & 계좌 비밀번호 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountHolder">예금주</Label>
|
||||
<Input
|
||||
id="accountHolder"
|
||||
value={formData.accountHolder}
|
||||
onChange={(e) => handleChange('accountHolder', e.target.value)}
|
||||
placeholder="예금주명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountPassword">
|
||||
계좌 비밀번호 (빠른 조회 서비스)
|
||||
</Label>
|
||||
<Input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
value={formData.accountPassword}
|
||||
onChange={(e) => handleChange('accountPassword', e.target.value)}
|
||||
placeholder="****"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계좌명 & 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountName">계좌명</Label>
|
||||
<Input
|
||||
id="accountName"
|
||||
value={formData.accountName}
|
||||
onChange={(e) => handleChange('accountName', e.target.value)}
|
||||
placeholder="계좌명을 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleChange('status', value as AccountStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreateMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
/**
|
||||
* 차량관리 공통 타입 정의
|
||||
* DB 마이그레이션 스키마 기반 (corporate_vehicles, vehicle_logs, vehicle_maintenances)
|
||||
*/
|
||||
|
||||
// ===== 차량 목록 (Corporate Vehicles) =====
|
||||
|
||||
export type OwnershipType = 'corporate' | 'rent' | 'lease';
|
||||
export type VehicleStatus = 'active' | 'maintenance' | 'disposed';
|
||||
export type VehicleType = '승용차' | '승합차' | '화물차' | 'SUV';
|
||||
|
||||
export interface CorporateVehicle {
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
model: string;
|
||||
vehicleType: VehicleType;
|
||||
ownershipType: OwnershipType;
|
||||
year: number | null;
|
||||
driver: string | null;
|
||||
status: VehicleStatus;
|
||||
mileage: number;
|
||||
memo: string | null;
|
||||
// 법인 전용
|
||||
purchaseDate: string | null;
|
||||
purchasePrice: number;
|
||||
// 렌트/리스 전용
|
||||
contractDate: string | null;
|
||||
rentCompany: string | null;
|
||||
rentCompanyTel: string | null;
|
||||
rentPeriod: string | null;
|
||||
agreedMileage: string | null;
|
||||
vehiclePrice: number;
|
||||
residualValue: number;
|
||||
deposit: number;
|
||||
monthlyRent: number;
|
||||
monthlyRentTax: number;
|
||||
insuranceCompany: string | null;
|
||||
insuranceCompanyTel: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// API 응답 (snake_case)
|
||||
export interface CorporateVehicleApi {
|
||||
id: number;
|
||||
plate_number: string;
|
||||
model: string;
|
||||
vehicle_type: string;
|
||||
ownership_type: string;
|
||||
year: number | null;
|
||||
driver: string | null;
|
||||
status: string;
|
||||
mileage: number;
|
||||
memo: string | null;
|
||||
purchase_date: string | null;
|
||||
purchase_price: number;
|
||||
contract_date: string | null;
|
||||
rent_company: string | null;
|
||||
rent_company_tel: string | null;
|
||||
rent_period: string | null;
|
||||
agreed_mileage: string | null;
|
||||
vehicle_price: number;
|
||||
residual_value: number;
|
||||
deposit: number;
|
||||
monthly_rent: number;
|
||||
monthly_rent_tax: number;
|
||||
insurance_company: string | null;
|
||||
insurance_company_tel: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function transformVehicleApi(api: CorporateVehicleApi): CorporateVehicle {
|
||||
return {
|
||||
id: api.id,
|
||||
plateNumber: api.plate_number,
|
||||
model: api.model,
|
||||
vehicleType: api.vehicle_type as VehicleType,
|
||||
ownershipType: api.ownership_type as OwnershipType,
|
||||
year: api.year,
|
||||
driver: api.driver,
|
||||
status: api.status as VehicleStatus,
|
||||
mileage: api.mileage,
|
||||
memo: api.memo,
|
||||
purchaseDate: api.purchase_date,
|
||||
purchasePrice: api.purchase_price,
|
||||
contractDate: api.contract_date,
|
||||
rentCompany: api.rent_company,
|
||||
rentCompanyTel: api.rent_company_tel,
|
||||
rentPeriod: api.rent_period,
|
||||
agreedMileage: api.agreed_mileage,
|
||||
vehiclePrice: api.vehicle_price,
|
||||
residualValue: api.residual_value,
|
||||
deposit: api.deposit,
|
||||
monthlyRent: api.monthly_rent,
|
||||
monthlyRentTax: api.monthly_rent_tax,
|
||||
insuranceCompany: api.insurance_company,
|
||||
insuranceCompanyTel: api.insurance_company_tel,
|
||||
createdAt: api.created_at,
|
||||
updatedAt: api.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VehicleFormData {
|
||||
plateNumber: string;
|
||||
vehicleType: VehicleType | '';
|
||||
ownershipType: OwnershipType | '';
|
||||
model: string;
|
||||
year: string;
|
||||
// 법인: 취득일, 렌트/리스: 계약일자
|
||||
purchaseDate: string;
|
||||
contractDate: string;
|
||||
// 법인: 구매처, 렌트/리스: 렌트회사명
|
||||
rentCompany: string;
|
||||
// 법인: 계약기간, 렌트/리스: 렌트기간
|
||||
rentPeriod: string;
|
||||
// 법인: 취득가(공급가), 렌트/리스: 월 렌트료(공급가)
|
||||
purchasePrice: string;
|
||||
monthlyRent: string;
|
||||
monthlyRentTax: string;
|
||||
rentCompanyTel: string;
|
||||
agreedMileage: string;
|
||||
vehiclePrice: string;
|
||||
residualValue: string;
|
||||
deposit: string;
|
||||
mileage: string;
|
||||
insuranceCompany: string;
|
||||
insuranceCompanyTel: string;
|
||||
driver: string;
|
||||
status: VehicleStatus | '';
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export const EMPTY_VEHICLE_FORM: VehicleFormData = {
|
||||
plateNumber: '',
|
||||
vehicleType: '',
|
||||
ownershipType: '',
|
||||
model: '',
|
||||
year: '',
|
||||
purchaseDate: '',
|
||||
contractDate: '',
|
||||
rentCompany: '',
|
||||
rentPeriod: '',
|
||||
purchasePrice: '',
|
||||
monthlyRent: '',
|
||||
monthlyRentTax: '',
|
||||
rentCompanyTel: '',
|
||||
agreedMileage: '',
|
||||
vehiclePrice: '',
|
||||
residualValue: '',
|
||||
deposit: '',
|
||||
mileage: '',
|
||||
insuranceCompany: '',
|
||||
insuranceCompanyTel: '',
|
||||
driver: '',
|
||||
status: '',
|
||||
memo: '',
|
||||
};
|
||||
|
||||
// 드롭다운용 차량 목록
|
||||
export interface VehicleDropdownItem {
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface VehicleDropdownApi {
|
||||
id: number;
|
||||
plate_number: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function transformVehicleDropdown(api: VehicleDropdownApi): VehicleDropdownItem {
|
||||
return {
|
||||
id: api.id,
|
||||
plateNumber: api.plate_number,
|
||||
model: api.model,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 차량일지 (Vehicle Logs) =====
|
||||
|
||||
export type TripType =
|
||||
| 'commute_to'
|
||||
| 'commute_from'
|
||||
| 'business'
|
||||
| 'personal'
|
||||
| 'commute_round'
|
||||
| 'business_round'
|
||||
| 'personal_round';
|
||||
|
||||
export type LocationType = 'home' | 'office' | 'client' | 'other';
|
||||
|
||||
export const TRIP_TYPE_LABELS: Record<TripType, string> = {
|
||||
commute_to: '출근',
|
||||
commute_from: '퇴근',
|
||||
business: '업무용',
|
||||
personal: '비업무',
|
||||
commute_round: '출퇴근(왕복)',
|
||||
business_round: '업무(왕복)',
|
||||
personal_round: '비업무(왕복)',
|
||||
};
|
||||
|
||||
export const TRIP_TYPE_COLORS: Record<TripType, string> = {
|
||||
commute_to: 'bg-green-100 text-green-700',
|
||||
commute_from: 'bg-green-100 text-green-700',
|
||||
business: 'bg-blue-100 text-blue-700',
|
||||
personal: 'bg-gray-100 text-gray-700',
|
||||
commute_round: 'bg-green-100 text-green-700',
|
||||
business_round: 'bg-blue-100 text-blue-700',
|
||||
personal_round: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
|
||||
home: '자택',
|
||||
office: '회사',
|
||||
client: '거래처',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export interface VehicleLog {
|
||||
id: number;
|
||||
vehicleId: number;
|
||||
logDate: string;
|
||||
department: string | null;
|
||||
driverName: string;
|
||||
tripType: TripType;
|
||||
departureType: LocationType;
|
||||
departureName: string | null;
|
||||
departureAddress: string | null;
|
||||
arrivalType: LocationType;
|
||||
arrivalName: string | null;
|
||||
arrivalAddress: string | null;
|
||||
distanceKm: number;
|
||||
note: string | null;
|
||||
// joined
|
||||
vehicle?: VehicleDropdownItem;
|
||||
}
|
||||
|
||||
export interface VehicleLogApi {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
log_date: string;
|
||||
department: string | null;
|
||||
driver_name: string;
|
||||
trip_type: string;
|
||||
departure_type: string;
|
||||
departure_name: string | null;
|
||||
departure_address: string | null;
|
||||
arrival_type: string;
|
||||
arrival_name: string | null;
|
||||
arrival_address: string | null;
|
||||
distance_km: number;
|
||||
note: string | null;
|
||||
vehicle?: VehicleDropdownApi;
|
||||
}
|
||||
|
||||
export function transformVehicleLogApi(api: VehicleLogApi): VehicleLog {
|
||||
return {
|
||||
id: api.id,
|
||||
vehicleId: api.vehicle_id,
|
||||
logDate: api.log_date,
|
||||
department: api.department,
|
||||
driverName: api.driver_name,
|
||||
tripType: api.trip_type as TripType,
|
||||
departureType: api.departure_type as LocationType,
|
||||
departureName: api.departure_name,
|
||||
departureAddress: api.departure_address,
|
||||
arrivalType: api.arrival_type as LocationType,
|
||||
arrivalName: api.arrival_name,
|
||||
arrivalAddress: api.arrival_address,
|
||||
distanceKm: api.distance_km,
|
||||
note: api.note,
|
||||
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VehicleLogFormData {
|
||||
vehicleId: string;
|
||||
logDate: string;
|
||||
department: string;
|
||||
driverName: string;
|
||||
tripType: TripType | '';
|
||||
departureType: LocationType | '';
|
||||
departureName: string;
|
||||
departureAddress: string;
|
||||
arrivalType: LocationType | '';
|
||||
arrivalName: string;
|
||||
arrivalAddress: string;
|
||||
distanceKm: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const EMPTY_LOG_FORM: VehicleLogFormData = {
|
||||
vehicleId: '',
|
||||
logDate: new Date().toISOString().slice(0, 10),
|
||||
department: '',
|
||||
driverName: '',
|
||||
tripType: '',
|
||||
departureType: '',
|
||||
departureName: '',
|
||||
departureAddress: '',
|
||||
arrivalType: '',
|
||||
arrivalName: '',
|
||||
arrivalAddress: '',
|
||||
distanceKm: '',
|
||||
note: '',
|
||||
};
|
||||
|
||||
export const NOTE_PRESETS = ['거래처방문', '제조시설등', '회의참석', '판촉활동', '교육등'];
|
||||
|
||||
export interface VehicleLogSummary {
|
||||
totalDistance: number;
|
||||
totalCount: number;
|
||||
commuteToDistance: number;
|
||||
commuteToCount: number;
|
||||
commuteFromDistance: number;
|
||||
commuteFromCount: number;
|
||||
businessDistance: number;
|
||||
businessCount: number;
|
||||
personalDistance: number;
|
||||
personalCount: number;
|
||||
}
|
||||
|
||||
// ===== 정비이력 (Vehicle Maintenance) =====
|
||||
|
||||
export type MaintenanceCategory = '주유' | '정비' | '보험' | '세차' | '주차' | '통행료' | '검사' | '기타';
|
||||
|
||||
export const MAINTENANCE_CATEGORIES: MaintenanceCategory[] = [
|
||||
'주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타',
|
||||
];
|
||||
|
||||
export const CATEGORY_COLORS: Record<MaintenanceCategory, string> = {
|
||||
'주유': 'bg-amber-100 text-amber-700',
|
||||
'정비': 'bg-blue-100 text-blue-700',
|
||||
'보험': 'bg-emerald-100 text-emerald-700',
|
||||
'세차': 'bg-cyan-100 text-cyan-700',
|
||||
'주차': 'bg-purple-100 text-purple-700',
|
||||
'통행료': 'bg-orange-100 text-orange-700',
|
||||
'검사': 'bg-indigo-100 text-indigo-700',
|
||||
'기타': 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export interface VehicleMaintenance {
|
||||
id: number;
|
||||
vehicleId: number;
|
||||
date: string;
|
||||
category: MaintenanceCategory;
|
||||
description: string;
|
||||
amount: number;
|
||||
mileage: number;
|
||||
vendor: string | null;
|
||||
memo: string | null;
|
||||
// joined
|
||||
vehicle?: VehicleDropdownItem;
|
||||
}
|
||||
|
||||
export interface VehicleMaintenanceApi {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
date: string;
|
||||
category: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
mileage: number;
|
||||
vendor: string | null;
|
||||
memo: string | null;
|
||||
vehicle?: { id: number; plate_number: string; model: string };
|
||||
}
|
||||
|
||||
export function transformMaintenanceApi(api: VehicleMaintenanceApi): VehicleMaintenance {
|
||||
return {
|
||||
id: api.id,
|
||||
vehicleId: api.vehicle_id,
|
||||
date: api.date,
|
||||
category: api.category as MaintenanceCategory,
|
||||
description: api.description,
|
||||
amount: api.amount,
|
||||
mileage: api.mileage,
|
||||
vendor: api.vendor,
|
||||
memo: api.memo,
|
||||
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MaintenanceFormData {
|
||||
vehicleId: string;
|
||||
date: string;
|
||||
category: MaintenanceCategory | '';
|
||||
description: string;
|
||||
amount: string;
|
||||
mileage: string;
|
||||
vendor: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export const EMPTY_MAINTENANCE_FORM: MaintenanceFormData = {
|
||||
vehicleId: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
category: '',
|
||||
description: '',
|
||||
amount: '',
|
||||
mileage: '',
|
||||
vendor: '',
|
||||
memo: '',
|
||||
};
|
||||
|
||||
// ===== 공통 유틸 =====
|
||||
|
||||
export const OWNERSHIP_LABELS: Record<OwnershipType, string> = {
|
||||
corporate: '법인차량',
|
||||
rent: '렌트차량',
|
||||
lease: '리스차량',
|
||||
};
|
||||
|
||||
export const OWNERSHIP_COLORS: Record<OwnershipType, string> = {
|
||||
corporate: 'bg-purple-100 text-purple-700',
|
||||
rent: 'bg-blue-100 text-blue-700',
|
||||
lease: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<VehicleStatus, string> = {
|
||||
active: '운행중',
|
||||
maintenance: '정비중',
|
||||
disposed: '처분',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<VehicleStatus, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
maintenance: 'bg-yellow-100 text-yellow-700',
|
||||
disposed: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export const VEHICLE_TYPES: VehicleType[] = ['승용차', '승합차', '화물차', 'SUV'];
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
return value.toLocaleString('ko-KR') + '원';
|
||||
}
|
||||
|
||||
export function formatDistance(value: number): string {
|
||||
return value.toLocaleString('ko-KR') + 'km';
|
||||
}
|
||||
Reference in New Issue
Block a user