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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
/**
* 완료함 서버 액션
*
* API Endpoints:
* - GET /api/v1/approvals/completed - 완료함 목록 조회
* - GET /api/v1/approvals/completed/summary - 완료함 통계
*/
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { CompletedRecord, CompletedStatus, ApprovalType } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface CompletedSummary {
total: number;
approved: number;
rejected: number;
}
interface CompletedApiData {
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?: Array<{
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;
}>;
created_at: string;
updated_at: string;
}
// ============================================
// 헬퍼 함수
// ============================================
function mapCompletedStatus(apiStatus: string): CompletedStatus {
if (apiStatus === 'rejected') return 'rejected';
return 'approved';
}
function mapApprovalType(formCategory?: string): ApprovalType {
const typeMap: Record<string, ApprovalType> = {
'expense_report': 'expense_report',
'proposal': 'proposal',
'expense_estimate': 'expense_estimate',
'document': 'document',
};
return typeMap[formCategory || ''] || 'proposal';
}
function transformApiToFrontend(data: CompletedApiData): CompletedRecord {
// 마지막 처리자 찾기
const lastProcessedStep = data.steps
?.filter(s => s.processed_at)
.sort((a, b) => (b.processed_at || '').localeCompare(a.processed_at || ''))
[0];
return {
id: String(data.id),
documentNo: data.document_number,
approvalType: mapApprovalType(data.form?.category),
formCode: data.form?.code,
formName: data.form?.name,
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 || '',
completedDate: lastProcessedStep?.processed_at?.replace('T', ' ').substring(0, 16) || '',
completedBy: lastProcessedStep?.approver?.name || '',
status: mapCompletedStatus(data.status),
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
// ============================================
// API 함수
// ============================================
export async function getCompleted(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: CompletedRecord[]; total: number; lastPage: number; __authError?: boolean }> {
const result = await executeServerAction<PaginatedApiResponse<CompletedApiData>>({
url: buildApiUrl('/api/v1/approvals/completed', {
page: params?.page,
per_page: params?.per_page,
search: params?.search,
status: params?.status && params.status !== 'all' ? 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 getCompletedSummary(): Promise<CompletedSummary | null> {
const result = await executeServerAction<CompletedSummary>({
url: buildApiUrl('/api/v1/approvals/completed/summary'),
errorMessage: '완료함 통계 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}

View File

@@ -0,0 +1,555 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useDateRange } from '@/hooks';
import {
Files,
CheckCircle2,
XCircle,
ClipboardList,
} from 'lucide-react';
import { toast } from 'sonner';
import { getCompleted, getCompletedSummary } from './actions';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import {
UniversalListPage,
type UniversalListConfig,
type StatCard,
type TabOption,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import type {
CompletedTabType,
CompletedRecord,
SortOption,
FilterOption,
ApprovalType,
} from './types';
import {
COMPLETED_TAB_LABELS,
SORT_OPTIONS,
FILTER_OPTIONS,
APPROVAL_TYPE_LABELS,
COMPLETED_STATUS_LABELS,
COMPLETED_STATUS_COLORS,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// ===== 통계 타입 =====
interface CompletedSummary {
all: number;
approved: number;
rejected: number;
}
export function CompletedBox() {
// ===== 상태 관리 =====
const [activeTab, setActiveTab] = useState<CompletedTabType>('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 [isModalOpen, setIsModalOpen] = useState(false);
const [isModalLoading, setIsModalLoading] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<CompletedRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
// API 데이터
const [data, setData] = useState<CompletedRecord[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const isInitialLoadDone = useRef(false);
// 통계 데이터
const [summary, setSummary] = useState<CompletedSummary | 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' };
default: return { sort_by: 'created_at', sort_dir: 'desc' };
}
})();
const statusParam = activeTab === 'all' ? undefined : activeTab;
const result = await getCompleted({
page: currentPage,
per_page: itemsPerPage,
search: searchQuery || undefined,
status: statusParam,
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 completed:', error);
toast.error('완료함 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
// ===== 통계 로드 =====
const loadSummary = useCallback(async () => {
try {
const result = await getCompletedSummary();
if (result) {
setSummary({
all: result.total,
approved: result.approved,
rejected: result.rejected,
});
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load summary:', error);
}
}, []);
// ===== 초기 로드 =====
useEffect(() => {
loadSummary();
}, []);
// ===== 데이터 로드 =====
useEffect(() => {
loadData();
}, [currentPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
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) {
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 CompletedTabType);
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]);
// ===== 통계 데이터 =====
const stats = useMemo(() => ({
all: summary?.all ?? 0,
approved: summary?.approved ?? 0,
rejected: summary?.rejected ?? 0,
}), [summary]);
// ===== 문서 클릭/상세 보기 핸들러 =====
const handleDocumentClick = useCallback(async (item: CompletedRecord) => {
setSelectedDocument(item);
setIsModalLoading(true);
setIsModalOpen(true);
try {
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
const docTypeCode = formData.basicInfo.documentType;
const drafter = {
id: 'drafter-1',
name: formData.basicInfo.drafter,
position: formData.basicInfo.drafterPosition || '',
department: formData.basicInfo.drafterDepartment || '',
status: 'approved' as const,
};
const approvers = formData.approvalLine.map((person, index) => ({
id: person.id,
name: person.name,
position: person.position,
department: person.department,
status: index === 0 ? ('approved' as const) : ('none' as const),
}));
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
if (!isBuiltin) {
const dedicatedDataMap: Record<string, unknown> = {
officialDocument: formData.officialDocumentData,
resignation: formData.resignationData,
employmentCert: formData.employmentCertData,
careerCert: formData.careerCertData,
appointmentCert: formData.appointmentCertData,
sealUsage: formData.sealUsageData,
leaveNotice1st: formData.leaveNotice1stData,
leaveNotice2nd: formData.leaveNotice2ndData,
powerOfAttorney: formData.powerOfAttorneyData,
boardMinutes: formData.boardMinutesData,
quotation: formData.quotationData,
};
const dedicatedData = dedicatedDataMap[docTypeCode];
const fields = dedicatedData
? filterVisibleFields(dedicatedData as Record<string, unknown>)
: (formData.dynamicFormData || {});
setModalDocType('dynamic');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
formName: formData.basicInfo.formName || getFormName(docTypeCode),
fields,
fieldLabels: getFieldLabels(docTypeCode),
approvers,
drafter,
});
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
setModalDocType('expenseEstimate');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
items: formData.expenseEstimateData?.items.map(i => ({
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
amount: i.amount, vendor: i.vendor, account: i.memo || '',
})) || [],
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
approvers, drafter,
});
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
setModalDocType('expenseReport');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
requestDate: formData.expenseReportData?.requestDate || '',
paymentDate: formData.expenseReportData?.paymentDate || '',
items: formData.expenseReportData?.items.map((i, idx) => ({
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
})) || [],
cardInfo: formData.expenseReportData?.cardId || '-',
totalAmount: formData.expenseReportData?.totalAmount || 0,
attachments: [], approvers, drafter,
});
} else {
setModalDocType('proposal');
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
vendor: formData.proposalData?.vendor || '-',
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
title: formData.proposalData?.title || item.title,
description: formData.proposalData?.description || '-',
reason: formData.proposalData?.reason || '-',
estimatedCost: formData.proposalData?.estimatedCost || 0,
attachments: uploadedFileUrls,
approvers, drafter,
});
}
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document:', error);
toast.error('문서를 불러오는데 실패했습니다.');
setIsModalOpen(false);
} finally {
setIsModalLoading(false);
}
}, []);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => [
{ label: '전체', value: `${stats.all}`, icon: Files, iconColor: 'text-blue-500' },
{ label: '승인', value: `${stats.approved}`, icon: CheckCircle2, iconColor: 'text-green-500' },
{ label: '반려', value: `${stats.rejected}`, icon: XCircle, iconColor: 'text-red-500' },
], [stats]);
// ===== 탭 옵션 =====
const tabs: TabOption[] = useMemo(() => [
{ value: 'all', label: COMPLETED_TAB_LABELS.all, count: stats.all, color: 'blue' },
{ value: 'approved', label: COMPLETED_TAB_LABELS.approved, count: stats.approved, color: 'green' },
{ value: 'rejected', label: COMPLETED_TAB_LABELS.rejected, count: stats.rejected, 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: 'completedDate', label: '완료일시', copyable: true },
{ key: 'completedBy', label: '최종처리자', copyable: true },
{ key: 'status', label: '상태', className: 'text-center' },
], []);
// ===== UniversalListPage 설정 =====
const completedBoxConfig: UniversalListConfig<CompletedRecord> = useMemo(() => ({
title: '완료함',
description: '결재가 완료된 문서를 확인합니다.',
icon: ClipboardList,
basePath: '/approval/completed',
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: CompletedRecord, 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: '완료함 필터',
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()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle()}
className="h-4 w-4 rounded border-gray-300"
/>
</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>{item.completedDate}</TableCell>
<TableCell>{item.completedBy}</TableCell>
<TableCell className="text-center">
<Badge className={COMPLETED_STATUS_COLORS[item.status]}>
{COMPLETED_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={COMPLETED_STATUS_COLORS[item.status]}>
{COMPLETED_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.draftDate} />
<InfoField label="완료일시" value={item.completedDate || '-'} />
<InfoField label="최종처리자" value={item.completedBy || '-'} />
</div>
}
/>
);
},
renderDialogs: () => (
<>
{selectedDocument && modalData && (
<DocumentDetailModal
open={isModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setModalData(null);
}}
documentType={modalDocType}
data={modalData}
mode="completed"
/>
)}
</>
),
}), [
data,
totalCount,
totalPages,
tableColumns,
tabs,
activeTab,
statCards,
startDate,
endDate,
handleDocumentClick,
selectedDocument,
isModalOpen,
modalData,
modalDocType,
]);
// 모바일 필터 변경 핸들러
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<CompletedRecord>
config={completedBoxConfig}
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}
/>
);
}

View File

@@ -0,0 +1,76 @@
/**
* 완료함 타입 정의
* 결재가 완료된 문서 (승인/반려/전결)
*/
// ===== 메인 탭 타입 =====
export type CompletedTabType = 'all' | 'approved' | 'rejected';
// 완료 유형
export type CompletedStatus = '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';
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
];
// ===== 완료 문서 레코드 =====
export interface CompletedRecord {
id: string;
documentNo: string;
approvalType: ApprovalType;
formCode?: string; // 양식코드
formName?: string; // 양식명
title: string;
draftDate: string;
drafter: string;
drafterDepartment: string;
drafterPosition: string;
completedDate: string; // 완료일시
completedBy: string; // 최종 처리자
status: CompletedStatus;
createdAt: string;
updatedAt: string;
}
// ===== 상수 정의 =====
export const COMPLETED_TAB_LABELS: Record<CompletedTabType, string> = {
all: '전체',
approved: '승인',
rejected: '반려',
};
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
expense_report: '지출결의서',
proposal: '품의서',
expense_estimate: '비용견적서',
document: '문서 결재',
};
export const COMPLETED_STATUS_LABELS: Record<CompletedStatus, string> = {
approved: '승인',
rejected: '반려',
};
export const COMPLETED_STATUS_COLORS: Record<CompletedStatus, string> = {
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
};

View File

@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { format } from 'date-fns';
import { getEmployeeOptions, getEmployeeAutoFill } from './form-actions';
import type { AppointmentCertData } from './types';
interface AppointmentCertFormProps {
data: AppointmentCertData;
onChange: (data: AppointmentCertData) => void;
}
export function AppointmentCertForm({ data, onChange }: AppointmentCertFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function loadEmployees() {
setIsLoadingEmployees(true);
const result = await getEmployeeOptions();
if (result.success) setEmployees(result.data);
setIsLoadingEmployees(false);
}
loadEmployees();
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
const result = await getEmployeeAutoFill(employeeId);
if (result.success && result.data) {
const e = result.data;
onChange({
...data,
employeeId,
employeeName: e.name,
residentNumber: e.residentNumber,
department: e.department,
phone: e.phone,
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
});
}
};
return (
<div className="space-y-6">
{/* 1. 인적 사항 */}
<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> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.employeeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.residentNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.phone} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 위촉 정보 */}
<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> </Label>
<DatePicker
value={data.appointmentPeriodStart}
onChange={(date) => onChange({ ...data, appointmentPeriodStart: date })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<DatePicker
value={data.appointmentPeriodEnd}
onChange={(date) => onChange({ ...data, appointmentPeriodEnd: date })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label> </Label>
<Input
placeholder="계약 자격을 입력해주세요"
value={data.contractQualification}
onChange={(e) => onChange({ ...data, contractQualification: e.target.value })}
/>
</div>
</div>
</div>
{/* 3. 발급 정보 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="사용 용도를 입력해주세요"
value={data.purpose}
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.issueDate}
onChange={(date) => onChange({ ...data, issueDate: date })}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ApprovalPerson } from './types';
import type { ApprovalPerson, StepType } from './types';
import { getEmployees } from './actions';
interface ApprovalLineSectionProps {
@@ -18,6 +18,11 @@ interface ApprovalLineSectionProps {
onChange: (data: ApprovalPerson[]) => void;
}
const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [
{ value: 'approval', label: '결재' },
{ value: 'agreement', label: '합의' },
];
export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) {
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
@@ -32,6 +37,7 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
department: '',
position: '',
name: '',
stepType: 'approval',
};
onChange([...data, newPerson]);
};
@@ -44,11 +50,17 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
const employee = employees.find((e) => e.id === employeeId);
if (employee) {
const newData = [...data];
newData[index] = { ...employee };
newData[index] = { ...employee, stepType: newData[index].stepType || 'approval' };
onChange(newData);
}
};
const handleStepTypeChange = (index: number, stepType: StepType) => {
const newData = [...data];
newData[index] = { ...newData[index], stepType };
onChange(newData);
};
return (
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
@@ -60,7 +72,12 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
</div>
<div className="space-y-3">
<div className="text-sm text-gray-500 mb-2"> / / </div>
<div className="grid grid-cols-[2rem_5rem_1fr_2.25rem] gap-2 text-sm text-gray-500 mb-2">
<span className="text-center"></span>
<span></span>
<span> / / </span>
<span />
</div>
{data.length === 0 ? (
<div className="text-center py-4 text-gray-400">
@@ -68,13 +85,32 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
</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>
<div key={`${person.id}-${index}`} className="grid grid-cols-[2rem_5rem_1fr_2.25rem] gap-2 items-center">
<span className="text-center text-sm text-gray-500">{index + 1}</span>
{/* 결재/합의 선택 */}
<Select
value={person.stepType || 'approval'}
onValueChange={(value) => handleStepTypeChange(index, value as StepType)}
>
<SelectTrigger className="h-9 text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 직원 선택 */}
<Select
value={person.id.startsWith('temp-') ? undefined : person.id}
onValueChange={(value) => handleChange(index, value)}
>
<SelectTrigger className="flex-1">
<SelectTrigger>
<SelectValue
placeholder={
person.name && !person.id.startsWith('temp-')
@@ -91,6 +127,7 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
@@ -105,4 +142,4 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
</div>
</div>
);
}
}

View File

@@ -1,28 +1,74 @@
'use client';
import { useCallback } from 'react';
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';
import { Checkbox } from '@/components/ui/checkbox';
import { FormSelector } from './FormSelector';
import type { BasicInfo } from './types';
interface BasicInfoSectionProps {
data: BasicInfo;
onChange: (data: BasicInfo) => void;
}
// 양식 코드 → documentType 매핑 (전용 폼 호환)
const FORM_CODE_TO_DOC_TYPE: Record<string, string> = {
// 기존 3종
'proposal': 'proposal',
'expense_report': 'expenseReport',
'expenseReport': 'expenseReport',
'expense_estimate': 'expenseEstimate',
'expenseEstimate': 'expenseEstimate',
// 일반
'official_letter': 'officialDocument',
// 인사/근태
'resignation': 'resignation',
'leave_promotion_1st': 'leaveNotice1st',
'leave_promotion_2nd': 'leaveNotice2nd',
'delegation': 'powerOfAttorney',
'board_minutes': 'boardMinutes',
// 증명서
'employment_cert': 'employmentCert',
'career_cert': 'careerCert',
'appointment_cert': 'appointmentCert',
'seal_usage': 'sealUsage',
// 재무
'quotation': 'quotation',
};
export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
const handleFormSelect = useCallback((form: { id: number; code: string; name: string; category: string } | null) => {
if (!form) {
onChange({
...data,
formId: undefined,
formCode: undefined,
formName: undefined,
formCategory: undefined,
documentType: 'proposal', // 기본값
});
return;
}
// 기존 전용 폼 코드면 documentType으로 매핑
const docType = FORM_CODE_TO_DOC_TYPE[form.code] || form.code;
onChange({
...data,
formId: form.id,
formCode: form.code,
formName: form.name,
formCategory: form.category,
documentType: docType,
});
}, [data, onChange]);
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="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* 기안자 */}
<div className="space-y-2">
<Label htmlFor="drafter"></Label>
@@ -50,32 +96,30 @@ export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
<Label htmlFor="documentNo"></Label>
<Input
id="documentNo"
placeholder="문서번호를 입력해주세요"
placeholder="자동 생성"
value={data.documentNo}
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
disabled
className="bg-gray-50"
/>
</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 className="flex items-end space-x-2 pb-2">
<Checkbox
id="isUrgent"
checked={data.isUrgent || false}
onCheckedChange={(checked) => onChange({ ...data, isUrgent: checked === true })}
/>
<Label htmlFor="isUrgent" className="cursor-pointer"></Label>
</div>
</div>
{/* 2단계 양식 선택 */}
<FormSelector
selectedFormId={data.formId}
selectedFormCode={data.formCode}
onFormSelect={handleFormSelect}
/>
</div>
);
}
}

View File

@@ -0,0 +1,175 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { BoardMinutesData } from './types';
interface BoardMinutesFormProps {
data: BoardMinutesData;
onChange: (data: BoardMinutesData) => void;
}
const RESULT_OPTIONS = [
{ value: 'approved', label: '가결' },
{ value: 'rejected', label: '부결' },
{ value: 'deferred', label: '보류' },
];
export function BoardMinutesForm({ data, onChange }: BoardMinutesFormProps) {
return (
<div className="space-y-6">
{/* 1. 일시/장소 */}
<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> <span className="text-red-500">*</span></Label>
<DatePicker
value={data.meetingDate}
onChange={(date) => onChange({ ...data, meetingDate: date })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
placeholder="장소를 입력해주세요"
value={data.meetingPlace}
onChange={(e) => onChange({ ...data, meetingPlace: e.target.value })}
/>
</div>
</div>
</div>
{/* 2. 출석 이사 / 감사 */}
<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> <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="이사 총수"
value={data.totalDirectors || ''}
onChange={(e) => onChange({ ...data, totalDirectors: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="출석 이사 수"
value={data.attendingDirectors || ''}
onChange={(e) => onChange({ ...data, attendingDirectors: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
placeholder="감사 총수"
value={data.totalAuditors || ''}
onChange={(e) => onChange({ ...data, totalAuditors: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
placeholder="출석 감사 수"
value={data.attendingAuditors || ''}
onChange={(e) => onChange({ ...data, attendingAuditors: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* 3. 의안 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="의안 제목을 입력해주세요"
value={data.agendaTitle}
onChange={(e) => onChange({ ...data, agendaTitle: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={data.agendaResult || ''}
onValueChange={(value) => onChange({ ...data, agendaResult: value })}
>
<SelectTrigger>
<SelectValue placeholder="결과를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{RESULT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 4. 의사경과 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="의장명을 입력해주세요"
value={data.chairperson}
onChange={(e) => onChange({ ...data, chairperson: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
placeholder="의사경과 내용을 입력해주세요"
value={data.proceedings}
onChange={(e) => onChange({ ...data, proceedings: e.target.value })}
className="min-h-[150px]"
/>
</div>
</div>
</div>
{/* 5. 폐회 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-2">
<Label> </Label>
<Input
placeholder="예: 16:00"
value={data.adjournmentTime}
onChange={(e) => onChange({ ...data, adjournmentTime: e.target.value })}
/>
</div>
</div>
{/* 6. 기명날인 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<Textarea
placeholder="기명날인 대상자를 입력해주세요 (예: 의장 홍길동, 이사 김영희)"
value={data.signatories}
onChange={(e) => onChange({ ...data, signatories: e.target.value })}
className="min-h-[80px]"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { format } from 'date-fns';
import { getEmployeeOptions, getEmployeeAutoFill, getCompanyAutoFill } from './form-actions';
import type { CareerCertData } from './types';
interface CareerCertFormProps {
data: CareerCertData;
onChange: (data: CareerCertData) => void;
}
export function CareerCertForm({ data, onChange }: CareerCertFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function load() {
setIsLoadingEmployees(true);
const [empResult, companyResult] = await Promise.all([
getEmployeeOptions(),
!data.companyName ? getCompanyAutoFill() : Promise.resolve(null),
]);
if (empResult.success) setEmployees(empResult.data);
if (companyResult?.success && companyResult.data) {
const c = companyResult.data;
onChange({
...data,
companyName: data.companyName || c.companyName,
businessNumber: data.businessNumber || c.businessNumber,
representativeName: data.representativeName || c.representativeName,
companyPhone: data.companyPhone || c.phone,
companyAddress: data.companyAddress || c.address,
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
});
}
setIsLoadingEmployees(false);
}
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
const result = await getEmployeeAutoFill(employeeId);
if (result.success && result.data) {
const e = result.data;
onChange({
...data,
employeeId,
employeeName: e.name,
residentNumber: e.residentNumber,
birthDate: e.birthDate,
employeeAddress: e.address,
department: e.department,
positionTitle: e.position,
workPeriodStart: e.joinDate,
});
}
};
return (
<div className="space-y-6">
{/* 1. 인적 사항 */}
<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> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.employeeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.residentNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.birthDate} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 경력 사항 */}
<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></Label>
<Input value={data.companyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.businessNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.representativeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.companyPhone} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.companyAddress} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label>/</Label>
<Input value={data.positionTitle} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label> </Label>
<DatePicker
value={data.workPeriodStart}
onChange={(date) => onChange({ ...data, workPeriodStart: date })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<DatePicker
value={data.workPeriodEnd}
onChange={(date) => onChange({ ...data, workPeriodEnd: date })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input
placeholder="담당업무를 입력해주세요"
value={data.duties}
onChange={(e) => onChange({ ...data, duties: e.target.value })}
/>
</div>
</div>
</div>
{/* 3. 발급 정보 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="사용 용도를 입력해주세요"
value={data.purpose}
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.issueDate}
onChange={(date) => onChange({ ...data, issueDate: date })}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,373 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Plus, Trash2 } from 'lucide-react';
import { getApprovalFormDetail } from './form-actions';
import type { ApprovalFormField } from './form-actions';
interface DynamicFormRendererProps {
formId: number;
data: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void;
onFieldsLoaded?: (fields: ApprovalFormField[]) => void;
}
/**
* 동적 폼 렌더러
* API template.fields 기반으로 폼 필드를 자동 생성
*/
export function DynamicFormRenderer({ formId, data, onChange, onFieldsLoaded }: DynamicFormRendererProps) {
const [fields, setFields] = useState<ApprovalFormField[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
setIsLoading(true);
setError(null);
const result = await getApprovalFormDetail(formId);
if (result.success && result.data) {
const loadedFields = result.data.template.fields || [];
setFields(loadedFields);
onFieldsLoaded?.(loadedFields);
} else {
setError(result.error || '양식 정보를 불러올 수 없습니다.');
}
setIsLoading(false);
};
load();
}, [formId]);
const handleFieldChange = useCallback((name: string, value: unknown) => {
onChange({ ...data, [name]: value });
}, [data, onChange]);
if (isLoading) {
return (
<div className="bg-white rounded-lg border p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-32" />
<div className="h-10 bg-gray-200 rounded" />
<div className="h-10 bg-gray-200 rounded" />
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg border p-6">
<p className="text-sm text-destructive">{error}</p>
</div>
);
}
if (fields.length === 0) {
return (
<div className="bg-white rounded-lg border p-6">
<p className="text-sm text-muted-foreground"> .</p>
</div>
);
}
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">
{fields.map((field) => (
<DynamicField
key={field.name}
field={field}
value={data[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
/>
))}
</div>
</div>
);
}
// ===== 개별 필드 렌더러 =====
interface DynamicFieldProps {
field: ApprovalFormField;
value: unknown;
onChange: (value: unknown) => void;
}
function DynamicField({ field, value, onChange }: DynamicFieldProps) {
const isFullWidth = field.type === 'textarea' || field.type === 'array' || field.type === 'daterange';
return (
<div className={`space-y-2 ${isFullWidth ? 'md:col-span-2' : ''}`}>
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderField(field, value, onChange)}
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
);
}
function renderField(field: ApprovalFormField, value: unknown, onChange: (value: unknown) => void) {
switch (field.type) {
case 'text':
return (
<Input
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || `${field.label} 입력`}
/>
);
case 'number':
return (
<Input
type="number"
value={(value as number) ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
placeholder={field.placeholder || '0'}
/>
);
case 'textarea':
return (
<Textarea
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || `${field.label} 입력`}
rows={4}
/>
);
case 'date':
return (
<DatePicker
value={(value as string) || ''}
onChange={(date) => onChange(date)}
placeholder={field.placeholder || '날짜 선택'}
/>
);
case 'daterange':
return <DateRangeField value={value as { start?: string; end?: string }} onChange={onChange} field={field} />;
case 'select':
return (
<Select
key={`${field.name}-${(value as string) || 'empty'}`}
value={(value as string) || undefined}
onValueChange={onChange}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || '선택'} />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2 pt-1">
<Checkbox
checked={(value as boolean) || false}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<span className="text-sm">{field.placeholder || field.label}</span>
</div>
);
case 'file':
return (
<Input
type="file"
onChange={(e) => {
const files = e.target.files;
if (files) onChange(Array.from(files));
}}
multiple
/>
);
case 'array':
return <ArrayField field={field} value={value as Record<string, unknown>[]} onChange={onChange} />;
default:
return (
<Input
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || `${field.label} 입력`}
/>
);
}
}
// ===== 날짜 범위 필드 =====
function DateRangeField({
value,
onChange,
field,
}: {
value?: { start?: string; end?: string };
onChange: (value: unknown) => void;
field: ApprovalFormField;
}) {
const rangeValue = value || { start: '', end: '' };
return (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<DatePicker
value={rangeValue.start || ''}
onChange={(date) => onChange({ ...rangeValue, start: date })}
placeholder="시작일"
/>
</div>
<span className="text-muted-foreground shrink-0">~</span>
<div className="flex-1 min-w-0">
<DatePicker
value={rangeValue.end || ''}
onChange={(date) => onChange({ ...rangeValue, end: date })}
placeholder="종료일"
/>
</div>
</div>
);
}
// ===== 배열(테이블) 필드 =====
function ArrayField({
field,
value,
onChange,
}: {
field: ApprovalFormField;
value?: Record<string, unknown>[];
onChange: (value: unknown) => void;
}) {
const rows = value || [];
const columns = field.columns || [];
const addRow = () => {
const newRow: Record<string, unknown> = {};
columns.forEach((col) => {
newRow[col.name] = col.type === 'number' ? 0 : '';
});
onChange([...rows, newRow]);
};
const removeRow = (index: number) => {
onChange(rows.filter((_, i) => i !== index));
};
const updateRow = (index: number, colName: string, colValue: unknown) => {
const updated = [...rows];
updated[index] = { ...updated[index], [colName]: colValue };
onChange(updated);
};
if (columns.length === 0) {
return <p className="text-sm text-muted-foreground"> .</p>;
}
return (
<div className="space-y-2">
<div className="overflow-x-auto">
<table className="w-full border text-sm">
<thead>
<tr className="bg-gray-50">
<th className="border px-2 py-1 w-10">No</th>
{columns.map((col) => (
<th key={col.name} className="border px-2 py-1">{col.label}</th>
))}
<th className="border px-2 py-1 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr key={rowIdx}>
<td className="border px-2 py-1 text-center">{rowIdx + 1}</td>
{columns.map((col) => (
<td key={col.name} className="border px-1 py-1">
{col.type === 'number' ? (
<Input
type="number"
className="h-8 text-sm"
value={(row[col.name] as number) ?? ''}
onChange={(e) => updateRow(rowIdx, col.name, e.target.value ? Number(e.target.value) : '')}
/>
) : col.type === 'date' ? (
<DatePicker
size="sm"
value={(row[col.name] as string) || ''}
onChange={(date) => updateRow(rowIdx, col.name, date)}
/>
) : col.type === 'select' ? (
<Select
key={`${col.name}-${rowIdx}-${(row[col.name] as string) || 'empty'}`}
value={(row[col.name] as string) || undefined}
onValueChange={(v) => updateRow(rowIdx, col.name, v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{(col.options || []).map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-sm"
value={(row[col.name] as string) || ''}
onChange={(e) => updateRow(rowIdx, col.name, e.target.value)}
/>
)}
</td>
))}
<td className="border px-1 py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeRow(rowIdx)}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { format } from 'date-fns';
import { getEmployeeOptions, getEmployeeAutoFill, getCompanyAutoFill } from './form-actions';
import type { EmploymentCertData } from './types';
interface EmploymentCertFormProps {
data: EmploymentCertData;
onChange: (data: EmploymentCertData) => void;
}
export function EmploymentCertForm({ data, onChange }: EmploymentCertFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function load() {
setIsLoadingEmployees(true);
const [empResult, companyResult] = await Promise.all([
getEmployeeOptions(),
!data.companyName ? getCompanyAutoFill() : Promise.resolve(null),
]);
if (empResult.success) setEmployees(empResult.data);
if (companyResult?.success && companyResult.data) {
const c = companyResult.data;
onChange({
...data,
companyName: data.companyName || c.companyName,
businessNumber: data.businessNumber || c.businessNumber,
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
});
}
setIsLoadingEmployees(false);
}
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
const result = await getEmployeeAutoFill(employeeId);
if (result.success && result.data) {
const e = result.data;
onChange({
...data,
employeeId,
employeeName: e.name,
residentNumber: e.residentNumber,
employeeAddress: e.address,
department: e.department,
position: e.position,
employmentPeriodStart: e.joinDate,
});
}
};
return (
<div className="space-y-6">
{/* 1. 인적 사항 */}
<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> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.employeeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.residentNumber} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 재직 사항 */}
<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></Label>
<Input value={data.companyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.businessNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.position} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label> </Label>
<DatePicker
value={data.employmentPeriodStart}
onChange={(date) => onChange({ ...data, employmentPeriodStart: date })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<DatePicker
value={data.employmentPeriodEnd}
onChange={(date) => onChange({ ...data, employmentPeriodEnd: date })}
/>
</div>
</div>
</div>
{/* 3. 발급 정보 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="사용 용도를 입력해주세요"
value={data.purpose}
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.issueDate}
onChange={(date) => onChange({ ...data, issueDate: date })}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,18 +1,14 @@
'use client';
import { Fragment } from 'react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { DatePicker } from '@/components/ui/date-picker';
import { formatNumber } from '@/lib/utils/amount';
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ExpenseEstimateFormProps {
data: ExpenseEstimateData;
@@ -20,145 +16,191 @@ interface ExpenseEstimateFormProps {
isLoading?: boolean;
}
/**
* 비용견적서 폼 — API 템플릿 필드 매칭
* items 배열 (expectedPaymentDate, category, amount, vendor, memo)
* + totalExpense, accountBalance, finalDifference
*/
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 recalcTotals = (items: ExpenseEstimateItem[], accountBalance: number) => {
const totalExpense = items.reduce((sum, item) => sum + (item.amount || 0), 0);
return { totalExpense, finalDifference: 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 handleItemChange = (index: number, field: keyof ExpenseEstimateItem, value: string | number | boolean) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
const { totalExpense, finalDifference } = recalcTotals(newItems, data.accountBalance);
onChange({ ...data, items: newItems, totalExpense, finalDifference });
};
const totalExpense = items.reduce((sum, item) => sum + item.amount, 0);
const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액
const finalDifference = accountBalance - totalExpense;
const handleAddItem = () => {
const newItem: ExpenseEstimateItem = {
id: `item-${Date.now()}`,
checked: false,
expectedPaymentDate: '',
category: '',
amount: 0,
vendor: '',
memo: '',
};
onChange({ ...data, items: [...data.items, newItem] });
};
const handleDeleteChecked = () => {
const remaining = data.items.filter(item => !item.checked);
const { totalExpense, finalDifference } = recalcTotals(remaining, data.accountBalance);
onChange({ ...data, items: remaining, totalExpense, finalDifference });
};
const handleAccountBalanceChange = (value: string) => {
const accountBalance = Number(value.replace(/,/g, '')) || 0;
const { totalExpense, finalDifference } = recalcTotals(data.items, accountBalance);
onChange({ ...data, accountBalance, totalExpense, finalDifference });
};
const checkedCount = data.items.filter(i => i.checked).length;
// 로딩 상태
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 className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mr-2" />
<span className="text-muted-foreground"> ...</span>
</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="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"> </h3>
<div className="flex items-center gap-2">
{checkedCount > 0 && (
<Button variant="outline" size="sm" onClick={handleDeleteChecked} className="text-red-600">
<Trash2 className="h-4 w-4 mr-1" />
({checkedCount})
</Button>
)}
<Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
<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>
))}
{/* 항목 테이블 */}
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-2 w-10 text-center">
<Checkbox
checked={data.items.length > 0 && data.items.every(i => i.checked)}
onCheckedChange={(checked) => {
const newItems = data.items.map(i => ({ ...i, checked: !!checked }));
onChange({ ...data, items: newItems });
}}
/>
</th>
<th className="p-2 w-10 text-center font-medium">No.</th>
<th className="p-2 font-medium w-36"> </th>
<th className="p-2 font-medium"></th>
<th className="p-2 font-medium w-32 text-right"></th>
<th className="p-2 font-medium w-28"></th>
<th className="p-2 font-medium w-32"></th>
</tr>
</thead>
<tbody>
{data.items.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-muted-foreground">
. &quot; &quot; .
</td>
</tr>
) : (
data.items.map((item, index) => (
<tr key={item.id} className="border-b last:border-b-0">
<td className="p-2 text-center">
<Checkbox
checked={item.checked}
onCheckedChange={(checked) => handleItemChange(index, 'checked', !!checked)}
/>
</td>
<td className="p-2 text-center text-muted-foreground">{index + 1}</td>
<td className="p-2">
<DatePicker
value={item.expectedPaymentDate}
onChange={(date) => handleItemChange(index, 'expectedPaymentDate', date)}
size="sm"
placeholder="날짜 선택"
/>
</td>
<td className="p-2">
<Input
value={item.category}
onChange={(e) => handleItemChange(index, 'category', e.target.value)}
placeholder="항목명"
className="h-8"
/>
</td>
<td className="p-2">
<CurrencyInput
value={item.amount || 0}
onChange={(v) => handleItemChange(index, 'amount', v ?? 0)}
className="h-8"
showCurrency={false}
/>
</td>
<td className="p-2">
<Input
value={item.vendor ?? ''}
onChange={(e) => handleItemChange(index, 'vendor', e.target.value)}
placeholder="거래처"
className="h-8"
/>
</td>
<td className="p-2">
<Input
value={item.memo ?? ''}
onChange={(e) => handleItemChange(index, 'memo', e.target.value)}
placeholder="비고"
className="h-8"
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 합계 행들 */}
<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 className="mt-4 space-y-3">
<div className="flex items-center justify-between border-t pt-4">
<Label className="font-semibold"> </Label>
<span className="text-lg font-bold text-red-600">
{formatNumber(data.totalExpense)}
</span>
</div>
<div className="flex items-center justify-between">
<Label className="font-semibold"> </Label>
<Input
type="text"
value={formatNumber(data.accountBalance)}
onChange={(e) => handleAccountBalanceChange(e.target.value)}
className="w-48 text-right h-8"
/>
</div>
<div className="flex items-center justify-between border-t pt-3">
<Label className="font-semibold"> </Label>
<span className={`text-lg font-bold ${data.finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
{formatNumber(data.finalDifference)}
</span>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getApprovalFormsByCategory } from './form-actions';
import type { ApprovalFormCategory, ApprovalFormItem } from './form-actions';
interface FormSelectorProps {
selectedFormId?: number;
selectedFormCode?: string;
onFormSelect: (form: { id: number; code: string; name: string; category: string } | null) => void;
disabled?: boolean;
}
/**
* 2단계 양식 선택 컴포넌트
* 1단계: 카테고리 선택 (재무, 인사, 총무 등)
* 2단계: 양식 선택 (카테고리 내 양식 목록)
*/
export function FormSelector({ selectedFormId, selectedFormCode, onFormSelect, disabled }: FormSelectorProps) {
const [categories, setCategories] = useState<ApprovalFormCategory[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
// 양식 목록 로드
useEffect(() => {
const load = async () => {
setIsLoading(true);
const result = await getApprovalFormsByCategory();
if (result.success) {
setCategories(result.data);
// 기존 선택값 복원
if (selectedFormCode || selectedFormId) {
for (const cat of result.data) {
const found = cat.forms.find(
f => f.id === selectedFormId || f.code === selectedFormCode
);
if (found) {
setSelectedCategory(cat.category);
break;
}
}
}
}
setIsLoading(false);
};
load();
}, [selectedFormId, selectedFormCode]);
// 현재 카테고리의 양식 목록
const currentForms = categories.find(c => c.category === selectedCategory)?.forms || [];
// 카테고리 변경
const handleCategoryChange = useCallback((category: string) => {
setSelectedCategory(category);
onFormSelect(null); // 카테고리 변경 시 양식 선택 초기화
}, [onFormSelect]);
// 양식 선택
const handleFormSelect = useCallback((formId: string) => {
const form = currentForms.find(f => String(f.id) === formId);
if (form) {
onFormSelect({
id: form.id,
code: form.code,
name: form.name,
category: form.category,
});
}
}, [currentForms, onFormSelect]);
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select disabled><SelectTrigger><SelectValue placeholder="로딩 중..." /></SelectTrigger></Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select disabled><SelectTrigger><SelectValue placeholder="로딩 중..." /></SelectTrigger></Select>
</div>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 1단계: 카테고리 선택 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={selectedCategory}
onValueChange={handleCategoryChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="분류 선택" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.category} value={cat.category}>
{cat.categoryLabel} ({cat.forms.length})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2단계: 양식 선택 */}
<div className="space-y-2">
<Label> </Label>
<Select
key={`form-${selectedCategory}-${selectedFormId}`}
value={selectedFormId ? String(selectedFormId) : ''}
onValueChange={handleFormSelect}
disabled={disabled || !selectedCategory}
>
<SelectTrigger>
<SelectValue placeholder={selectedCategory ? '양식 선택' : '분류를 먼저 선택하세요'} />
</SelectTrigger>
<SelectContent>
{currentForms.map((form) => (
<SelectItem key={form.id} value={String(form.id)}>
{form.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getEmployeeOptions, getEmployeeAutoFill, getLeaveBalanceAutoFill } from './form-actions';
import type { LeaveNotice1stData } from './types';
interface LeaveNotice1stFormProps {
data: LeaveNotice1stData;
onChange: (data: LeaveNotice1stData) => void;
}
const DEFAULT_LEGAL_NOTICE = `근로기준법 제61조에 의거하여 미사용 연차유급휴가에 대하여 사용을 촉진합니다.\n\n아래 잔여 연차를 기한 내에 사용하여 주시기 바랍니다. 기한 내 미사용 시 사용시기를 지정하여 통보할 예정이며, 회사가 지정한 시기에 사용하지 않을 경우 해당 연차는 소멸됩니다.`;
export function LeaveNotice1stForm({ data, onChange }: LeaveNotice1stFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function loadEmployees() {
setIsLoadingEmployees(true);
const result = await getEmployeeOptions();
if (result.success) setEmployees(result.data);
setIsLoadingEmployees(false);
}
loadEmployees();
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
// 직원 정보 + 연차 잔여 동시 조회
const [empResult, leaveResult] = await Promise.all([
getEmployeeAutoFill(employeeId),
getLeaveBalanceAutoFill(parseInt(employeeId)),
]);
const updates: Partial<LeaveNotice1stData> = { employeeId };
if (empResult.success && empResult.data) {
updates.department = empResult.data.department;
updates.position = empResult.data.position;
}
if (leaveResult.success && leaveResult.data) {
updates.totalDays = leaveResult.data.totalDays;
updates.usedDays = leaveResult.data.usedDays;
updates.remainingDays = leaveResult.data.remainingDays;
}
if (!data.legalNotice) {
updates.legalNotice = DEFAULT_LEGAL_NOTICE;
}
onChange({ ...data, ...updates });
};
return (
<div className="space-y-6">
{/* 1. 수신자 */}
<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-3 gap-4">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '직원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.position} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 연차 현황 */}
<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-3 gap-4">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
type="number"
value={data.totalDays || ''}
onChange={(e) => onChange({ ...data, totalDays: Number(e.target.value) })}
placeholder="발생 일수"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={data.usedDays || ''}
onChange={(e) => onChange({ ...data, usedDays: Number(e.target.value) })}
placeholder="사용 일수"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={data.remainingDays || ''}
onChange={(e) => onChange({ ...data, remainingDays: Number(e.target.value) })}
placeholder="잔여 일수"
/>
</div>
</div>
</div>
{/* 3. 제출 기한 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={data.deadline}
onChange={(date) => onChange({ ...data, deadline: date })}
/>
</div>
</div>
{/* 4. 법적 통지 문구 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<Textarea
value={data.legalNotice}
onChange={(e) => onChange({ ...data, legalNotice: e.target.value })}
className="min-h-[150px]"
placeholder="법적 통지 문구"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, X } from 'lucide-react';
import { getEmployeeOptions, getEmployeeAutoFill, getLeaveBalanceAutoFill } from './form-actions';
import type { LeaveNotice2ndData } from './types';
interface LeaveNotice2ndFormProps {
data: LeaveNotice2ndData;
onChange: (data: LeaveNotice2ndData) => void;
}
const LEGAL_NOTICE_PARAGRAPHS = [
'귀하는 연차 사용촉진 1차 통보 이후에도 연차 사용 시기를 제출하지 않아 근로기준법 제61조에 따라 회사가 다음과 같이 휴가 사용일을 지정합니다.',
'위 지정된 날짜에 연차휴가를 사용하여 주시기 바랍니다.',
'본 통지서는 근로기준법 제61조에 따른 연차 사용촉진 절차에 의한 통보입니다.',
];
const LEGAL_NOTICE_TEXT = LEGAL_NOTICE_PARAGRAPHS.join('\n\n');
export function LeaveNotice2ndForm({ data, onChange }: LeaveNotice2ndFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function loadEmployees() {
setIsLoadingEmployees(true);
const result = await getEmployeeOptions();
if (result.success) setEmployees(result.data);
setIsLoadingEmployees(false);
}
loadEmployees();
}, []);
// 법적 통지 문구 자동 설정
useEffect(() => {
if (!data.legalNotice) {
onChange({ ...data, legalNotice: LEGAL_NOTICE_TEXT });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
const [empResult, leaveResult] = await Promise.all([
getEmployeeAutoFill(employeeId),
getLeaveBalanceAutoFill(parseInt(employeeId)),
]);
const updates: Partial<LeaveNotice2ndData> = { employeeId };
if (empResult.success && empResult.data) {
updates.department = empResult.data.department;
updates.position = empResult.data.position;
}
if (leaveResult.success && leaveResult.data) {
updates.remainingDays = leaveResult.data.remainingDays;
}
onChange({ ...data, ...updates });
};
const handleAddDate = () => {
onChange({ ...data, designatedDates: [...data.designatedDates, ''] });
};
const handleDateChange = (index: number, value: string) => {
const newDates = [...data.designatedDates];
newDates[index] = value;
onChange({ ...data, designatedDates: newDates });
};
const handleRemoveDate = (index: number) => {
const newDates = data.designatedDates.filter((_, i) => i !== index);
onChange({ ...data, designatedDates: newDates.length > 0 ? newDates : [''] });
};
return (
<div className="space-y-6">
{/* 1. 수신자 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">1. </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '직원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.position} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 잔여 연차 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">2. </h3>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
type="number"
value={data.remainingDays || ''}
onChange={(e) => onChange({ ...data, remainingDays: Number(e.target.value) })}
placeholder="잔여 일수"
/>
</div>
</div>
{/* 3. 회사 지정 휴가일 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">
3. <span className="text-red-500">*</span>
</h3>
<div className="space-y-3">
{data.designatedDates.map((date, index) => (
<div key={index} className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-600 w-6">{index + 1}.</span>
<DatePicker
value={date}
onChange={(value) => handleDateChange(index, value)}
placeholder="날짜를 선택하세요"
/>
{data.designatedDates.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDate(index)}
className="text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddDate}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<p className="text-sm text-muted-foreground">* .</p>
</div>
</div>
{/* 4. 법적 통지 문구 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">4. </h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
{LEGAL_NOTICE_PARAGRAPHS.map((paragraph, index) => (
<p key={index} className="text-sm text-red-800">{paragraph}</p>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
import { getCompanyAutoFill } from './form-actions';
import type { OfficialDocumentData } from './types';
interface OfficialDocumentFormProps {
data: OfficialDocumentData;
onChange: (data: OfficialDocumentData) => void;
}
export function OfficialDocumentForm({ data, onChange }: OfficialDocumentFormProps) {
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
// 회사 정보 자동입력
useEffect(() => {
async function loadCompanyInfo() {
setIsLoadingCompany(true);
const result = await getCompanyAutoFill();
if (result.success && result.data) {
onChange({
...data,
documentDate: data.documentDate || format(new Date(), 'yyyy-MM-dd'),
companyName: data.companyName || result.data.companyName,
representativeName: data.representativeName || result.data.representativeName,
address: data.address || result.data.address,
phone: data.phone || result.data.phone,
fax: data.fax || result.data.fax,
email: data.email || result.data.email,
});
}
setIsLoadingCompany(false);
}
if (!data.companyName) {
loadCompanyInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
{/* 1. 문서 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">1. </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
placeholder="예: 2026030601"
value={data.documentNumber}
onChange={(e) => onChange({ ...data, documentNumber: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={data.documentDate}
onChange={(date) => onChange({ ...data, documentDate: date })}
/>
</div>
</div>
</div>
{/* 2. 수신 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">2. </h3>
<div className="space-y-4">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
placeholder="수신처"
value={data.recipient}
onChange={(e) => onChange({ ...data, recipient: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="참조 (선택사항)"
value={data.reference}
onChange={(e) => onChange({ ...data, reference: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
placeholder="공문서 제목"
value={data.title}
onChange={(e) => onChange({ ...data, title: e.target.value })}
/>
</div>
</div>
</div>
{/* 3. 본문 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">3. </h3>
<Textarea
placeholder="본문 내용을 입력해주세요"
value={data.body}
onChange={(e) => onChange({ ...data, body: e.target.value })}
className="min-h-[200px]"
/>
</div>
{/* 4. 붙임 (첨부서류) */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">4. ()</h3>
<Textarea
placeholder="첨부 서류 목록을 입력해주세요"
value={data.attachment}
onChange={(e) => onChange({ ...data, attachment: e.target.value })}
className="min-h-[100px]"
/>
</div>
{/* 5. 발신자 (회사 자동입력) */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">
5.
{isLoadingCompany && <span className="text-sm text-gray-400 ml-2"> ...</span>}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={data.companyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.representativeName} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.address} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={data.phone}
onChange={(e) => onChange({ ...data, phone: e.target.value })}
placeholder="전화번호"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={data.fax}
onChange={(e) => onChange({ ...data, fax: e.target.value })}
placeholder="팩스번호"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={data.email}
onChange={(e) => onChange({ ...data, email: e.target.value })}
placeholder="이메일"
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import { getCompanyAutoFill } from './form-actions';
import type { PowerOfAttorneyData } from './types';
interface PowerOfAttorneyFormProps {
data: PowerOfAttorneyData;
onChange: (data: PowerOfAttorneyData) => void;
}
export function PowerOfAttorneyForm({ data, onChange }: PowerOfAttorneyFormProps) {
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
// 위임인(회사) 정보 자동입력
useEffect(() => {
async function loadCompanyInfo() {
setIsLoadingCompany(true);
const result = await getCompanyAutoFill();
if (result.success && result.data) {
onChange({
...data,
principalCompanyName: data.principalCompanyName || result.data.companyName,
principalBusinessNumber: data.principalBusinessNumber || result.data.businessNumber,
principalAddress: data.principalAddress || result.data.address,
principalRepresentative: data.principalRepresentative || result.data.representativeName,
});
}
setIsLoadingCompany(false);
}
if (!data.principalCompanyName) {
loadCompanyInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
{/* 1. 위임인 (회사 정보) */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold"></h3>
{isLoadingCompany && <span className="text-sm text-gray-400"> ...</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={data.principalCompanyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.principalBusinessNumber} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.principalAddress} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.principalRepresentative} disabled className="bg-gray-50" />
</div>
</div>
</div>
{/* 2. 수임인 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="성명을 입력해주세요"
value={data.agentName}
onChange={(e) => onChange({ ...data, agentName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.agentBirthDate}
onChange={(date) => onChange({ ...data, agentBirthDate: date })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input
placeholder="주소를 입력해주세요"
value={data.agentAddress}
onChange={(e) => onChange({ ...data, agentAddress: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="연락처를 입력해주세요"
value={data.agentPhone}
onChange={(e) => onChange({ ...data, agentPhone: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="소속을 입력해주세요"
value={data.agentDepartment}
onChange={(e) => onChange({ ...data, agentDepartment: e.target.value })}
/>
</div>
</div>
</div>
{/* 3. 위임 사항 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<Textarea
placeholder="위임 사항을 입력해주세요"
value={data.delegationDetails}
onChange={(e) => onChange({ ...data, delegationDetails: e.target.value })}
className="min-h-[120px]"
/>
</div>
{/* 4. 위임 기간 */}
<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></Label>
<DatePicker
value={data.delegationPeriodStart}
onChange={(date) => onChange({ ...data, delegationPeriodStart: date })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.delegationPeriodEnd}
onChange={(date) => onChange({ ...data, delegationPeriodEnd: date })}
/>
</div>
</div>
</div>
{/* 5. 첨부 서류 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<Textarea
placeholder="첨부 서류 목록을 입력해주세요"
value={data.attachedDocuments}
onChange={(e) => onChange({ ...data, attachedDocuments: e.target.value })}
className="min-h-[80px]"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,304 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { CurrencyInput } from '@/components/ui/currency-input';
import { format } from 'date-fns';
import { formatNumber } from '@/lib/utils/amount';
import { getCompanyAutoFill } from './form-actions';
import type { QuotationData, QuotationItem } from './types';
interface QuotationFormProps {
data: QuotationData;
onChange: (data: QuotationData) => void;
}
function createEmptyItem(): QuotationItem {
return {
id: `item-${Date.now()}`,
name: '',
specification: '',
quantity: 1,
unitPrice: 0,
supplyAmount: 0,
tax: 0,
note: '',
};
}
function calcItem(item: QuotationItem, autoTax: boolean): QuotationItem {
const supplyAmount = item.quantity * item.unitPrice;
const tax = autoTax ? Math.round(supplyAmount * 0.1) : 0;
return { ...item, supplyAmount, tax };
}
export function QuotationForm({ data, onChange }: QuotationFormProps) {
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
// 공급자(회사) 정보 자동입력
useEffect(() => {
async function loadCompanyInfo() {
setIsLoadingCompany(true);
const result = await getCompanyAutoFill();
if (result.success && result.data) {
const c = result.data;
onChange({
...data,
businessNumber: data.businessNumber || c.businessNumber,
companyName: data.companyName || c.companyName,
representativeName: data.representativeName || c.representativeName,
companyAddress: data.companyAddress || c.address,
businessType: data.businessType || c.businessType,
businessCategory: data.businessCategory || c.businessCategory,
companyPhone: data.companyPhone || c.phone,
quotationDate: data.quotationDate || format(new Date(), 'yyyy-MM-dd'),
});
}
setIsLoadingCompany(false);
}
if (!data.companyName) {
loadCompanyInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddItem = () => {
onChange({ ...data, items: [...data.items, createEmptyItem()] });
};
const handleRemoveItem = (index: number) => {
const items = data.items.filter((_, i) => i !== index);
onChange({ ...data, items });
};
const handleItemChange = (index: number, field: keyof QuotationItem, value: string | number) => {
const items = [...data.items];
const item = { ...items[index], [field]: value };
items[index] = calcItem(item, data.autoTax);
onChange({ ...data, items });
};
const handleAutoTaxChange = (checked: boolean) => {
const items = data.items.map((item) => calcItem(item, checked));
onChange({ ...data, autoTax: checked, items });
};
// 합계 계산
const totalSupply = data.items.reduce((sum, item) => sum + item.supplyAmount, 0);
const totalTax = data.items.reduce((sum, item) => sum + item.tax, 0);
const grandTotal = totalSupply + totalTax;
return (
<div className="space-y-6">
{/* 1. 수신 정보 */}
<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>/ <span className="text-red-500">*</span></Label>
<Input
placeholder="고객명을 입력해주세요"
value={data.recipientName}
onChange={(e) => onChange({ ...data, recipientName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={data.quotationDate}
onChange={(date) => onChange({ ...data, quotationDate: date })}
/>
</div>
</div>
</div>
{/* 2. 공급자 정보 (회사 자동입력) */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4">
{isLoadingCompany && <span className="text-sm text-gray-400 ml-2"> ...</span>}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={data.businessNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.companyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.representativeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.companyAddress} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={data.businessType}
onChange={(e) => onChange({ ...data, businessType: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={data.businessCategory}
onChange={(e) => onChange({ ...data, businessCategory: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.companyPhone} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="(은행명) 계좌번호"
value={data.accountNumber}
onChange={(e) => onChange({ ...data, accountNumber: e.target.value })}
/>
</div>
</div>
</div>
{/* 3. 견적 품목 */}
<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>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<Checkbox
id="autoTax"
checked={data.autoTax}
onCheckedChange={(checked) => handleAutoTaxChange(checked === true)}
/>
<Label htmlFor="autoTax" className="text-sm cursor-pointer"> (10%)</Label>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{data.items.length === 0 ? (
<div className="text-center text-sm text-gray-400 py-8 border rounded">
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border">
<thead>
<tr className="bg-gray-50">
<th className="border px-2 py-2 w-10">#</th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-20"></th>
<th className="border px-2 py-2 w-28"></th>
<th className="border px-2 py-2 w-28"></th>
<th className="border px-2 py-2 w-24"></th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-10"></th>
</tr>
</thead>
<tbody>
{data.items.map((item, index) => (
<tr key={item.id}>
<td className="border px-2 py-1 text-center">{index + 1}</td>
<td className="border px-1 py-1">
<Input
value={item.name}
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
className="h-8 border-0"
placeholder="품명"
/>
</td>
<td className="border px-1 py-1">
<Input
value={item.specification}
onChange={(e) => handleItemChange(index, 'specification', e.target.value)}
className="h-8 border-0"
placeholder="규격"
/>
</td>
<td className="border px-1 py-1">
<Input
type="number"
value={item.quantity || ''}
onChange={(e) => handleItemChange(index, 'quantity', Number(e.target.value))}
className="h-8 border-0 text-right"
/>
</td>
<td className="border px-1 py-1">
<CurrencyInput
value={item.unitPrice}
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
className="h-8 border-0"
/>
</td>
<td className="border px-2 py-1 text-right bg-gray-50">
{formatNumber(item.supplyAmount)}
</td>
<td className="border px-2 py-1 text-right bg-gray-50">
{formatNumber(item.tax)}
</td>
<td className="border px-1 py-1">
<Input
value={item.note}
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
className="h-8 border-0"
/>
</td>
<td className="border px-1 py-1 text-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(index)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-50 font-semibold">
<td colSpan={5} className="border px-2 py-2 text-right"> </td>
<td className="border px-2 py-2 text-right">{formatNumber(totalSupply)}</td>
<td className="border px-2 py-2 text-right">{formatNumber(totalTax)}</td>
<td colSpan={2} className="border"></td>
</tr>
<tr className="bg-blue-50 font-semibold">
<td colSpan={5} className="border px-2 py-2 text-right"> ( + )</td>
<td colSpan={4} className="border px-2 py-2 text-right">{formatNumber(grandTotal)}</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
{/* 4. 특이사항 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<Textarea
placeholder="예: *부가세액 별도* 특이사항을 적어주세요"
value={data.specialNotes}
onChange={(e) => onChange({ ...data, specialNotes: e.target.value })}
className="min-h-[80px]"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { format } from 'date-fns';
import { getEmployeeOptions, getEmployeeAutoFill } from './form-actions';
import type { ResignationData } from './types';
interface ResignationFormProps {
data: ResignationData;
onChange: (data: ResignationData) => void;
}
const REASON_OPTIONS = [
{ value: 'personal', label: '개인 사유' },
{ value: 'health', label: '건강 문제' },
{ value: 'family', label: '가정 사정' },
{ value: 'career', label: '이직/전직' },
{ value: 'study', label: '학업' },
{ value: 'other', label: '기타' },
];
export function ResignationForm({ data, onChange }: ResignationFormProps) {
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
useEffect(() => {
async function loadEmployees() {
setIsLoadingEmployees(true);
const result = await getEmployeeOptions();
if (result.success) setEmployees(result.data);
setIsLoadingEmployees(false);
}
loadEmployees();
}, []);
const handleEmployeeSelect = async (employeeId: string) => {
onChange({ ...data, employeeId });
const result = await getEmployeeAutoFill(employeeId);
if (result.success && result.data) {
const e = result.data;
onChange({
...data,
employeeId,
employeeName: e.name,
department: e.department,
position: e.position,
residentNumber: e.residentNumber,
joinDate: e.joinDate,
employeeAddress: e.address,
submitDate: data.submitDate || format(new Date(), 'yyyy-MM-dd'),
});
}
};
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="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
value={data.employeeId || ''}
onValueChange={handleEmployeeSelect}
disabled={isLoadingEmployees}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{employees.map((emp) => (
<SelectItem key={emp.id} value={emp.id}>
{emp.name} ({emp.department})
</SelectItem>
))}
</SelectContent>
</Select>
</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></Label>
<Input value={data.department} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.position} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.employeeName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.residentNumber} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.joinDate} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={data.resignDate}
onChange={(date) => onChange({ ...data, resignDate: date })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
</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> <span className="text-red-500">*</span></Label>
<Select
value={data.reasonType || ''}
onValueChange={(value) => onChange({ ...data, reasonType: value })}
>
<SelectTrigger>
<SelectValue placeholder="사유를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{REASON_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Textarea
placeholder="상세 사유를 입력해주세요"
value={data.reasonDetail}
onChange={(e) => onChange({ ...data, reasonDetail: e.target.value })}
className="min-h-[100px]"
/>
</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-2">
<Label></Label>
<DatePicker
value={data.submitDate}
onChange={(date) => onChange({ ...data, submitDate: date })}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
import { getCompanyAutoFill } from './form-actions';
import type { SealUsageData } from './types';
interface SealUsageFormProps {
data: SealUsageData;
onChange: (data: SealUsageData) => void;
}
export function SealUsageForm({ data, onChange }: SealUsageFormProps) {
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
useEffect(() => {
async function loadCompanyInfo() {
setIsLoadingCompany(true);
const result = await getCompanyAutoFill();
if (result.success && result.data) {
const c = result.data;
onChange({
...data,
companyName: data.companyName || c.companyName,
businessNumber: data.businessNumber || c.businessNumber,
address: data.address || c.address,
representativeName: data.representativeName || c.representativeName,
usageDate: data.usageDate || format(new Date(), 'yyyy-MM-dd'),
});
}
setIsLoadingCompany(false);
}
if (!data.companyName) {
loadCompanyInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
{/* 1. 인감 날인 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="space-y-2">
<Label> </Label>
<Textarea
placeholder="사용할 인감 정보를 입력해주세요"
value={data.sealImprint}
onChange={(e) => onChange({ ...data, sealImprint: e.target.value })}
className="min-h-[80px]"
/>
</div>
</div>
{/* 2. 사용 정보 */}
<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> <span className="text-red-500">*</span></Label>
<Input
placeholder="사용 용도를 입력해주세요"
value={data.purpose}
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
placeholder="제출처를 입력해주세요"
value={data.submitTo}
onChange={(e) => onChange({ ...data, submitTo: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
placeholder="첨부 서류를 입력해주세요"
value={data.attachedDocuments}
onChange={(e) => onChange({ ...data, attachedDocuments: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={data.usageDate}
onChange={(date) => onChange({ ...data, usageDate: date })}
/>
</div>
</div>
</div>
{/* 3. 회사 정보 */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold"> </h3>
{isLoadingCompany && <span className="text-sm text-gray-400"> ...</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={data.companyName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.businessNumber} disabled className="bg-gray-50" />
</div>
<div className="md:col-span-2 space-y-2">
<Label></Label>
<Input value={data.address} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={data.representativeName} disabled className="bg-gray-50" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -243,12 +243,13 @@ export async function createApproval(formData: DocumentFormData): Promise<{
}
const requestBody = {
form_code: formData.basicInfo.documentType,
form_code: formData.basicInfo.formCode || formData.basicInfo.documentType,
title: getDocumentTitle(formData),
status: 'draft',
is_urgent: formData.basicInfo.isUrgent || false,
steps: [
...formData.approvalLine.map((person, index) => ({
step_type: 'approval',
step_type: person.stepType || 'approval',
step_order: index + 1,
approver_id: parseInt(person.id),
})),
@@ -259,6 +260,7 @@ export async function createApproval(formData: DocumentFormData): Promise<{
})),
],
content: getDocumentContent(formData, uploadedFiles),
...(getDocumentBody(formData) !== undefined && { body: getDocumentBody(formData) }),
};
const result = await executeServerAction<ApprovalCreateResponse>({
@@ -365,11 +367,12 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
}
const requestBody = {
form_code: formData.basicInfo.documentType,
form_code: formData.basicInfo.formCode || formData.basicInfo.documentType,
title: getDocumentTitle(formData),
is_urgent: formData.basicInfo.isUrgent || false,
steps: [
...formData.approvalLine.map((person, index) => ({
step_type: 'approval',
step_type: person.stepType || 'approval',
step_order: index + 1,
approver_id: parseInt(person.id),
})),
@@ -380,6 +383,7 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
})),
],
content: getDocumentContent(formData, uploadedFiles),
...(getDocumentBody(formData) !== undefined && { body: getDocumentBody(formData) }),
};
const result = await executeServerAction<ApprovalCreateResponse>({
@@ -456,9 +460,27 @@ function getDocumentTitle(formData: DocumentFormData): string {
case 'expenseReport':
return `지출결의서 - ${formData.expenseReportData?.requestDate || ''}`;
case 'expenseEstimate':
return `지출 예상 내역서`;
return formData.expenseEstimateData?.title || '비용견적서';
default: {
// 전용 양식 중 title 필드가 있는 경우 활용
const titleFromForm = formData.officialDocumentData?.title
|| formData.quotationData?.recipientName;
if (titleFromForm) {
return `${formData.basicInfo.formName || '문서'} - ${titleFromForm}`;
}
return formData.basicInfo.formName || '문서';
}
}
}
/**
* 문서 본문(body) 추출 — body 필드를 사용하는 양식만 반환
*/
function getDocumentBody(formData: DocumentFormData): string | undefined {
// 현재 body 필드를 사용하는 양식 없음 (비용견적서는 content로 전환됨)
switch (formData.basicInfo.documentType) {
default:
return '문서';
return undefined;
}
}
@@ -494,7 +516,9 @@ function transformApiToFormData(apiData: {
};
title: string;
status: string;
is_urgent?: boolean;
content: Record<string, unknown>;
body?: string;
steps?: Array<{
step_type: string;
approver_id: number;
@@ -526,7 +550,26 @@ function transformApiToFormData(apiData: {
}): DocumentFormData {
// form.code를 우선 사용, 없으면 form_code (이전 호환성)
const formCode = apiData.form?.code || apiData.form_code || 'proposal';
const documentType = formCode as 'proposal' | 'expenseReport' | 'expenseEstimate';
// API code → 프론트엔드 documentType 매핑
const FORM_CODE_MAP: Record<string, string> = {
'proposal': 'proposal',
'expense_report': 'expenseReport',
'expenseReport': 'expenseReport',
'expense_estimate': 'expenseEstimate',
'expenseEstimate': 'expenseEstimate',
'official_letter': 'officialDocument',
'resignation': 'resignation',
'leave_promotion_1st': 'leaveNotice1st',
'leave_promotion_2nd': 'leaveNotice2nd',
'delegation': 'powerOfAttorney',
'board_minutes': 'boardMinutes',
'employment_cert': 'employmentCert',
'career_cert': 'careerCert',
'appointment_cert': 'appointmentCert',
'seal_usage': 'sealUsage',
'quotation': 'quotation',
};
const documentType = FORM_CODE_MAP[formCode] || formCode;
const content = apiData.content || {};
// 결재선 및 참조자 분리
@@ -550,6 +593,7 @@ function transformApiToFormData(apiData: {
name: step.approver.name,
position,
department,
stepType: (step.step_type === 'agreement' ? 'agreement' : 'approval') as ApprovalPerson['stepType'],
};
// 'approval'과 'agreement' 모두 결재선에 포함
@@ -573,6 +617,11 @@ function transformApiToFormData(apiData: {
draftDate: apiData.created_at,
documentNo: apiData.document_number,
documentType,
formId: apiData.form?.id,
formCode: apiData.form?.code || formCode,
formName: apiData.form?.name,
formCategory: apiData.form?.category,
isUrgent: apiData.is_urgent || false,
};
// 기존 업로드 파일 추출
@@ -645,8 +694,10 @@ function transformApiToFormData(apiData: {
}>) || [];
expenseEstimateData = {
items: items.map(item => ({
id: item.id,
title: (apiData.title as string) || '',
body: (apiData.body as string) || '',
items: items.map((item, idx) => ({
id: item.id || `restored-${idx}-${Date.now()}`,
checked: item.checked || false,
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
@@ -660,6 +711,25 @@ function transformApiToFormData(apiData: {
};
}
// 12개 전용 양식 데이터: content에서 각 전용 폼 데이터로 매핑
const DEDICATED_FORM_TYPES = [
'officialDocument', 'resignation', 'employmentCert', 'careerCert',
'appointmentCert', 'sealUsage', 'leaveNotice1st', 'leaveNotice2nd',
'powerOfAttorney', 'boardMinutes', 'quotation',
];
const isDedicatedBuiltin = ['proposal', 'expenseReport', 'expenseEstimate'].includes(documentType);
const isDedicatedNew = DEDICATED_FORM_TYPES.includes(documentType);
// 동적 폼 데이터: 전용 폼이 아닌 경우에만 content를 그대로 사용
const dynamicFormData = (isDedicatedBuiltin || isDedicatedNew) ? undefined : content;
// 전용 양식별 데이터 복원
const dedicatedFormDataResult: Record<string, unknown> = {};
if (isDedicatedNew && Object.keys(content).length > 0) {
const key = `${documentType}Data`;
dedicatedFormDataResult[key] = content;
}
return {
basicInfo,
approvalLine,
@@ -667,6 +737,8 @@ function transformApiToFormData(apiData: {
proposalData,
expenseReportData,
expenseEstimateData,
dynamicFormData,
...dedicatedFormDataResult,
};
}
@@ -708,12 +780,36 @@ function getDocumentContent(
};
case 'expenseEstimate':
return {
items: formData.expenseEstimateData?.items,
items: formData.expenseEstimateData?.items?.map(item => ({
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
amount: item.amount,
vendor: item.vendor,
memo: item.memo,
})),
totalExpense: formData.expenseEstimateData?.totalExpense,
accountBalance: formData.expenseEstimateData?.accountBalance,
finalDifference: formData.expenseEstimateData?.finalDifference,
};
default:
return {};
default: {
// 12개 전용 양식: 해당 폼 데이터를 content로 직렬화
const dedicatedFormDataMap: Record<string, unknown> = {
officialDocument: formData.officialDocumentData,
resignation: formData.resignationData,
employmentCert: formData.employmentCertData,
careerCert: formData.careerCertData,
appointmentCert: formData.appointmentCertData,
sealUsage: formData.sealUsageData,
leaveNotice1st: formData.leaveNotice1stData,
leaveNotice2nd: formData.leaveNotice2ndData,
powerOfAttorney: formData.powerOfAttorneyData,
boardMinutes: formData.boardMinutesData,
quotation: formData.quotationData,
};
const dedicatedData = dedicatedFormDataMap[formData.basicInfo.documentType];
if (dedicatedData) return dedicatedData as Record<string, unknown>;
// 동적 폼 데이터
return formData.dynamicFormData || {};
}
}
}

View File

@@ -0,0 +1,299 @@
/**
* 결재 양식(approval-forms) 서버 액션
*
* API Endpoints:
* - GET /api/v1/approval-forms - 양식 목록 (active)
* - GET /api/v1/approval-forms/{id} - 양식 상세 (template 포함)
*/
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ============================================
// 타입 정의
// ============================================
export interface ApprovalFormItem {
id: number;
name: string;
code: string;
category: string;
}
export interface ApprovalFormField {
name: string;
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'checkbox' | 'file' | 'daterange' | 'array';
label: string;
required?: boolean;
options?: string[];
placeholder?: string;
description?: string;
columns?: ApprovalFormField[]; // array 타입의 하위 필드
}
export interface ApprovalFormDetail {
id: number;
name: string;
code: string;
category: string;
template: {
fields: ApprovalFormField[];
};
}
// category별 그룹 (2단계 Select용)
export interface ApprovalFormCategory {
category: string;
categoryLabel: string;
forms: ApprovalFormItem[];
}
// ============================================
// 공통 자동입력 데이터 타입
// ============================================
export interface CompanyAutoFill {
companyName: string;
representativeName: string;
businessNumber: string;
address: string;
phone: string;
fax: string;
email: string;
businessType: string;
businessCategory: string;
}
export interface EmployeeAutoFill {
id: string;
name: string;
department: string;
position: string;
residentNumber: string;
birthDate: string;
address: string;
phone: string;
joinDate: string;
}
export interface LeaveBalanceAutoFill {
totalDays: number;
usedDays: number;
remainingDays: number;
}
// ============================================
// API 함수
// ============================================
/**
* 활성 양식 목록 조회
*/
export async function getApprovalForms(): Promise<{ success: boolean; data: ApprovalFormItem[]; error?: string }> {
const result = await executeServerAction<ApprovalFormItem[] | { data: ApprovalFormItem[] }>({
url: buildApiUrl('/api/v1/approval-forms'),
errorMessage: '양식 목록 조회에 실패했습니다.',
});
if (!result.success) return { success: false, data: [], error: result.error };
// API 응답이 배열 또는 { data: [...] } 형태 모두 처리
const rawData = result.data;
let forms: ApprovalFormItem[] = [];
if (Array.isArray(rawData)) {
forms = rawData;
} else if (rawData && typeof rawData === 'object' && 'data' in rawData && Array.isArray(rawData.data)) {
forms = rawData.data;
}
return { success: true, data: forms };
}
/**
* 양식 상세 조회 (template.fields 포함)
*/
export async function getApprovalFormDetail(id: number): Promise<{ success: boolean; data?: ApprovalFormDetail; error?: string }> {
const result = await executeServerAction<ApprovalFormDetail>({
url: buildApiUrl(`/api/v1/approval-forms/${id}`),
errorMessage: '양식 상세 조회에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error };
return { success: true, data: result.data || undefined };
}
/**
* 양식 목록을 카테고리별로 그룹핑 (2단계 Select용)
*/
export async function getApprovalFormsByCategory(): Promise<{ success: boolean; data: ApprovalFormCategory[]; error?: string }> {
const result = await getApprovalForms();
if (!result.success) return { success: false, data: [], error: result.error };
const categoryMap = new Map<string, ApprovalFormItem[]>();
for (const form of result.data) {
const existing = categoryMap.get(form.category) || [];
existing.push(form);
categoryMap.set(form.category, existing);
}
const CATEGORY_LABELS: Record<string, string> = {
'일반': '일반',
'경비': '경비',
'hr': '인사',
'general': '총무',
'expense': '재무',
'request': '총무/기타',
'certificate': '증명서',
'finance': '재무',
'document': '문서',
'proposal': '품의',
'expense_report': '지출',
'expense_estimate': '예산',
};
const data: ApprovalFormCategory[] = Array.from(categoryMap.entries()).map(([category, forms]) => ({
category,
categoryLabel: CATEGORY_LABELS[category] || category,
forms,
}));
return { success: true, data };
}
// ============================================
// 공통 자동입력 함수 (전용 폼에서 사용)
// ============================================
/**
* 회사 정보 자동입력용 조회
*/
export async function getCompanyAutoFill(): Promise<{ success: boolean; data?: CompanyAutoFill; error?: string }> {
const result = await executeServerAction<{
company_name?: string;
representative_name?: string;
business_number?: string;
address?: string;
phone?: string;
fax?: string;
email?: string;
business_type?: string;
business_category?: string;
[key: string]: unknown;
}>({
url: buildApiUrl('/api/v1/tenant/company-info'),
errorMessage: '회사 정보 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const d = result.data;
return {
success: true,
data: {
companyName: d.company_name || '',
representativeName: d.representative_name || '',
businessNumber: d.business_number || '',
address: d.address || '',
phone: d.phone || '',
fax: d.fax || '',
email: d.email || '',
businessType: d.business_type || '',
businessCategory: d.business_category || '',
},
};
}
/**
* 직원 상세 정보 자동입력용 조회
*/
export async function getEmployeeAutoFill(employeeId: string): Promise<{ success: boolean; data?: EmployeeAutoFill; error?: string }> {
const result = await executeServerAction<{
id?: number;
user?: { id?: number; name?: string };
department?: { id?: number; name?: string } | null;
position?: { id?: number; name?: string; key?: string } | null;
resident_number?: string;
birth_date?: string;
address?: string;
phone?: string;
hire_date?: string;
[key: string]: unknown;
}>({
url: buildApiUrl(`/api/v1/employees/${employeeId}`),
errorMessage: '직원 정보 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const d = result.data;
return {
success: true,
data: {
id: String(d.user?.id || d.id || ''),
name: d.user?.name || '',
department: d.department?.name || '',
position: d.position?.name || '',
residentNumber: d.resident_number || '',
birthDate: d.birth_date || '',
address: d.address || '',
phone: d.phone || '',
joinDate: d.hire_date || '',
},
};
}
/**
* 직원 목록 조회 (Select 옵션용)
*/
export async function getEmployeeOptions(): Promise<{ success: boolean; data: { id: string; name: string; department: string }[]; error?: string }> {
const result = await executeServerAction<{
data?: Array<{
id: number;
user?: { id?: number; name?: string };
department?: { name?: string } | null;
}>;
}>({
url: buildApiUrl('/api/v1/employees', { per_page: 200, status: 'active' }),
errorMessage: '직원 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
const employees = (result.data.data || []).map((e) => ({
id: String(e.id),
name: e.user?.name || '',
department: e.department?.name || '',
}));
return { success: true, data: employees };
}
/**
* 직원 연차 잔여일수 조회
*/
export async function getLeaveBalanceAutoFill(userId: number, year?: number): Promise<{ success: boolean; data?: LeaveBalanceAutoFill; error?: string }> {
const y = year || new Date().getFullYear();
const result = await executeServerAction<{
total_days?: number;
used_days?: number;
remaining_days?: number;
[key: string]: unknown;
}>({
url: buildApiUrl(`/api/v1/leaves/balance/${userId}`, { year: y }),
errorMessage: '연차 잔여일수 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const d = result.data;
return {
success: true,
data: {
totalDays: d.total_days || 0,
usedDays: d.used_days || 0,
remainingDays: d.remaining_days || 0,
},
};
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
@@ -31,12 +31,33 @@ import { ReferenceSection } from './ReferenceSection';
import { ProposalForm } from './ProposalForm';
import { ExpenseReportForm } from './ExpenseReportForm';
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import { OfficialDocumentForm } from './OfficialDocumentForm';
import { ResignationForm } from './ResignationForm';
import { LeaveNotice1stForm } from './LeaveNotice1stForm';
import { LeaveNotice2ndForm } from './LeaveNotice2ndForm';
import { PowerOfAttorneyForm } from './PowerOfAttorneyForm';
import { BoardMinutesForm } from './BoardMinutesForm';
import { EmploymentCertForm } from './EmploymentCertForm';
import { CareerCertForm } from './CareerCertForm';
import { AppointmentCertForm } from './AppointmentCertForm';
import { SealUsageForm } from './SealUsageForm';
import { QuotationForm } from './QuotationForm';
import { DynamicFormRenderer } from './DynamicFormRenderer';
import type { ApprovalFormField } from './form-actions';
import { DEDICATED_FORM_CODES } from './types';
function isDedicatedForm(docType: string): boolean {
return (DEDICATED_FORM_CODES as readonly string[]).includes(docType);
}
// V2: DynamicDocument 렌더링 지원 (14개 전용 양식 미리보기)
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import { getFieldLabels, filterVisibleFields } from '@/components/approval/DocumentDetail/field-labels';
import type {
DocumentType as ModalDocumentType,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
DynamicDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
BasicInfo,
@@ -44,6 +65,17 @@ import type {
ProposalData,
ExpenseReportData,
ExpenseEstimateData,
OfficialDocumentData,
ResignationData,
EmploymentCertData,
CareerCertData,
AppointmentCertData,
SealUsageData,
LeaveNotice1stData,
LeaveNotice2ndData,
PowerOfAttorneyData,
BoardMinutesData,
QuotationData,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
@@ -78,6 +110,8 @@ const getInitialExpenseReportData = (): ExpenseReportData => ({
});
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
title: '',
body: '',
items: [],
totalExpense: 0,
accountBalance: 10000000,
@@ -87,18 +121,22 @@ const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
export function DocumentCreate() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [isSubmitting, setIsSubmitting] = useState(false);
const currentUser = useAuthStore((state) => state.currentUser);
const { canCreate, canDelete } = usePermission();
// 수정 모드 / 복제 모드 상태
const documentId = searchParams.get('id');
const urlDocumentId = 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);
// BUG #11 fix: 임시저장 후 edit 모드 전환을 위한 상태
const [savedDocId, setSavedDocId] = useState<string | null>(null);
const documentId = urlDocumentId || savedDocId;
const isEditMode = (mode === 'edit' && !!urlDocumentId) || !!savedDocId;
// 상태 관리
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
@@ -106,7 +144,29 @@ export function DocumentCreate() {
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
const [dynamicFormData, setDynamicFormData] = useState<Record<string, unknown>>({});
const [dynamicFormFields, setDynamicFormFields] = useState<ApprovalFormField[]>([]);
const [officialDocumentData, setOfficialDocumentData] = useState<OfficialDocumentData>({ documentNumber: '', documentDate: '', recipient: '', reference: '', title: '', body: '', attachment: '', companyName: '', representativeName: '', address: '', phone: '', fax: '', email: '' });
const [resignationData, setResignationData] = useState<ResignationData>({ employeeId: '', department: '', position: '', employeeName: '', residentNumber: '', joinDate: '', resignDate: '', employeeAddress: '', reasonType: '', reasonDetail: '', submitDate: '' });
const [employmentCertData, setEmploymentCertData] = useState<EmploymentCertData>({ employeeId: '', employeeName: '', residentNumber: '', employeeAddress: '', companyName: '', businessNumber: '', department: '', position: '', employmentPeriodStart: '', employmentPeriodEnd: '', purpose: '', issueDate: '' });
const [careerCertData, setCareerCertData] = useState<CareerCertData>({ employeeId: '', employeeName: '', residentNumber: '', birthDate: '', employeeAddress: '', companyName: '', businessNumber: '', representativeName: '', companyPhone: '', companyAddress: '', department: '', positionTitle: '', workPeriodStart: '', workPeriodEnd: '', duties: '', purpose: '', issueDate: '' });
const [appointmentCertData, setAppointmentCertData] = useState<AppointmentCertData>({ employeeId: '', employeeName: '', residentNumber: '', department: '', phone: '', appointmentPeriodStart: '', appointmentPeriodEnd: '', contractQualification: '', purpose: '', issueDate: '' });
const [sealUsageData, setSealUsageData] = useState<SealUsageData>({ sealImprint: '', purpose: '', submitTo: '', attachedDocuments: '', usageDate: '', companyName: '', businessNumber: '', address: '', representativeName: '' });
const [leaveNotice1stData, setLeaveNotice1stData] = useState<LeaveNotice1stData>({ employeeId: '', department: '', position: '', totalDays: 0, usedDays: 0, remainingDays: 0, deadline: '', legalNotice: '' });
const [leaveNotice2ndData, setLeaveNotice2ndData] = useState<LeaveNotice2ndData>({ employeeId: '', department: '', position: '', remainingDays: 0, designatedDates: [''], legalNotice: '' });
const [powerOfAttorneyData, setPowerOfAttorneyData] = useState<PowerOfAttorneyData>({ principalCompanyName: '', principalBusinessNumber: '', principalAddress: '', principalRepresentative: '', agentName: '', agentBirthDate: '', agentAddress: '', agentPhone: '', agentDepartment: '', delegationDetails: '', delegationPeriodStart: '', delegationPeriodEnd: '', attachedDocuments: '' });
const [boardMinutesData, setBoardMinutesData] = useState<BoardMinutesData>({ meetingDate: '', meetingPlace: '', totalDirectors: 0, attendingDirectors: 0, totalAuditors: 0, attendingAuditors: 0, agendaTitle: '', agendaResult: '', chairperson: '', proceedings: '', adjournmentTime: '', signatories: '' });
const [quotationData, setQuotationData] = useState<QuotationData>({ recipientName: '', quotationDate: '', businessNumber: '', companyName: '', representativeName: '', companyAddress: '', businessType: '', businessCategory: '', companyPhone: '', accountNumber: '', autoTax: true, items: [], specialNotes: '' });
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false);
const prevFormIdRef = useRef<number | undefined>(undefined);
// 양식 변경 시 동적 폼 데이터 초기화
useEffect(() => {
if (prevFormIdRef.current !== undefined && basicInfo.formId !== prevFormIdRef.current) {
setDynamicFormData({});
}
prevFormIdRef.current = basicInfo.formId;
}, [basicInfo.formId]);
// 복제 모드 toast 중복 호출 방지
const copyToastShownRef = useRef(false);
@@ -210,23 +270,39 @@ export function DocumentCreate() {
}
}, [isEditMode, isCopyMode, currentUser?.name]));
// 수정 모드: 문서 로드
// 수정 모드: 문서 로드 (URL 기반으로만 — savedDocId로 전환 시에는 재로딩하지 않음)
useEffect(() => {
if (!isEditMode || !documentId) return;
if (!(mode === 'edit' && urlDocumentId)) return;
const loadDocument = async () => {
setIsLoadingDocument(true);
try {
const result = await getApprovalById(parseInt(documentId));
const result = await getApprovalById(parseInt(urlDocumentId));
if (result.success && result.data) {
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
const loaded = result.data;
setBasicInfo(loadedBasicInfo);
setApprovalLine(loadedApprovalLine);
setReferences(loadedReferences);
if (loadedProposalData) setProposalData(loadedProposalData);
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
setBasicInfo(loaded.basicInfo);
setApprovalLine(loaded.approvalLine);
setReferences(loaded.references);
if (loaded.proposalData) setProposalData(loaded.proposalData);
if (loaded.expenseReportData) setExpenseReportData(loaded.expenseReportData);
if (loaded.expenseEstimateData) setExpenseEstimateData(loaded.expenseEstimateData);
if (loaded.dynamicFormData) setDynamicFormData(loaded.dynamicFormData);
// 12개 전용 양식 데이터 복원
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = loaded as any;
if (d.officialDocumentData) setOfficialDocumentData(d.officialDocumentData);
if (d.resignationData) setResignationData(d.resignationData);
if (d.employmentCertData) setEmploymentCertData(d.employmentCertData);
if (d.careerCertData) setCareerCertData(d.careerCertData);
if (d.appointmentCertData) setAppointmentCertData(d.appointmentCertData);
if (d.sealUsageData) setSealUsageData(d.sealUsageData);
if (d.leaveNotice1stData) setLeaveNotice1stData(d.leaveNotice1stData);
if (d.leaveNotice2ndData) setLeaveNotice2ndData(d.leaveNotice2ndData);
if (d.powerOfAttorneyData) setPowerOfAttorneyData(d.powerOfAttorneyData);
if (d.boardMinutesData) setBoardMinutesData(d.boardMinutesData);
if (d.quotationData) setQuotationData(d.quotationData);
} else {
toast.error(result.error || '문서를 불러오는데 실패했습니다.');
router.back();
@@ -242,7 +318,7 @@ export function DocumentCreate() {
};
loadDocument();
}, [isEditMode, documentId, router]);
}, [mode, urlDocumentId, router]);
// 복제 모드: 원본 문서 로드 후 새 문서로 설정
useEffect(() => {
@@ -253,24 +329,40 @@ export function DocumentCreate() {
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 loaded = result.data;
// 복제: 문서번호 초기화, 기안일 현재 시간으로
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo({
...loadedBasicInfo,
...loaded.basicInfo,
documentNo: '', // 새 문서이므로 문서번호 초기화
draftDate: now,
});
// 결재선/참조는 그대로 유지
setApprovalLine(loadedApprovalLine);
setReferences(loadedReferences);
setApprovalLine(loaded.approvalLine);
setReferences(loaded.references);
// 문서 내용 복제
if (loadedProposalData) setProposalData(loadedProposalData);
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
if (loaded.proposalData) setProposalData(loaded.proposalData);
if (loaded.expenseReportData) setExpenseReportData(loaded.expenseReportData);
if (loaded.expenseEstimateData) setExpenseEstimateData(loaded.expenseEstimateData);
if (loaded.dynamicFormData) setDynamicFormData(loaded.dynamicFormData);
// 12개 전용 양식 데이터 복원
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = loaded as any;
if (d.officialDocumentData) setOfficialDocumentData(d.officialDocumentData);
if (d.resignationData) setResignationData(d.resignationData);
if (d.employmentCertData) setEmploymentCertData(d.employmentCertData);
if (d.careerCertData) setCareerCertData(d.careerCertData);
if (d.appointmentCertData) setAppointmentCertData(d.appointmentCertData);
if (d.sealUsageData) setSealUsageData(d.sealUsageData);
if (d.leaveNotice1stData) setLeaveNotice1stData(d.leaveNotice1stData);
if (d.leaveNotice2ndData) setLeaveNotice2ndData(d.leaveNotice2ndData);
if (d.powerOfAttorneyData) setPowerOfAttorneyData(d.powerOfAttorneyData);
if (d.boardMinutesData) setBoardMinutesData(d.boardMinutesData);
if (d.quotationData) setQuotationData(d.quotationData);
// React.StrictMode에서 useEffect 두 번 실행으로 인한 toast 중복 방지
if (!copyToastShownRef.current) {
@@ -300,12 +392,13 @@ export function DocumentCreate() {
try {
const result = await getExpenseEstimateItems();
if (result) {
setExpenseEstimateData({
setExpenseEstimateData(prev => ({
...prev,
items: result.items,
totalExpense: result.totalExpense,
accountBalance: result.accountBalance,
finalDifference: result.finalDifference,
});
}));
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -325,15 +418,28 @@ export function DocumentCreate() {
// 폼 데이터 수집
const getFormData = useCallback(() => {
const dt = basicInfo.documentType;
return {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
proposalData: dt === 'proposal' ? proposalData : undefined,
expenseReportData: dt === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: dt === 'expenseEstimate' ? expenseEstimateData : undefined,
officialDocumentData: dt === 'officialDocument' ? officialDocumentData : undefined,
resignationData: dt === 'resignation' ? resignationData : undefined,
employmentCertData: dt === 'employmentCert' ? employmentCertData : undefined,
careerCertData: dt === 'careerCert' ? careerCertData : undefined,
appointmentCertData: dt === 'appointmentCert' ? appointmentCertData : undefined,
sealUsageData: dt === 'sealUsage' ? sealUsageData : undefined,
leaveNotice1stData: dt === 'leaveNotice1st' ? leaveNotice1stData : undefined,
leaveNotice2ndData: dt === 'leaveNotice2nd' ? leaveNotice2ndData : undefined,
powerOfAttorneyData: dt === 'powerOfAttorney' ? powerOfAttorneyData : undefined,
boardMinutesData: dt === 'boardMinutes' ? boardMinutesData : undefined,
quotationData: dt === 'quotation' ? quotationData : undefined,
dynamicFormData: isDedicatedForm(dt) ? undefined : dynamicFormData,
};
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, officialDocumentData, resignationData, employmentCertData, careerCertData, appointmentCertData, sealUsageData, leaveNotice1stData, leaveNotice2ndData, powerOfAttorneyData, boardMinutesData, quotationData, dynamicFormData]);
// 핸들러
const handleBack = useCallback(() => {
@@ -349,22 +455,23 @@ export function DocumentCreate() {
// 수정 모드: 실제 문서 삭제
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('문서 삭제 중 오류가 발생했습니다.');
setIsSubmitting(true);
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('문서 삭제 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
} else {
// 새 문서: 그냥 뒤로가기
router.back();
@@ -373,86 +480,135 @@ export function DocumentCreate() {
const handleSubmit = useCallback(async () => {
// 유효성 검사
if (!basicInfo.formId) {
toast.error('양식을 선택해주세요.');
return;
}
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('문서 상신 중 오류가 발생했습니다.');
// BUG #14 fix: 동적 폼 필수 필드 프론트엔드 검증
if (dynamicFormFields.length > 0) {
const requiredFields = dynamicFormFields.filter(f => f.required);
const missingFields = requiredFields.filter(f => {
const val = dynamicFormData[f.name];
return val === '' || val === null || val === undefined;
});
if (missingFields.length > 0) {
toast.error(`필수 항목을 입력해주세요: ${missingFields.map(f => f.label).join(', ')}`);
return;
}
});
}
setIsSubmitting(true);
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}`,
});
// BUG #1 fix: router.push로 기안함 이동하여 데이터 확실히 로드
router.push('/approval/draft');
} else {
toast.error(result.error || '문서 상신에 실패했습니다.');
}
} else {
// 새 문서: 생성 후 상신
const result = await createAndSubmitApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
// BUG #1 fix: router.push로 기안함 이동하여 데이터 확실히 로드
router.push('/approval/draft');
} else {
toast.error(result.error || '문서 상신에 실패했습니다.');
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Submit error:', error);
toast.error('문서 상신 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
}, [approvalLine, getFormData, router, isEditMode, documentId]);
const handleSaveDraft = useCallback(async () => {
startTransition(async () => {
try {
const formData = getFormData();
if (!basicInfo.formId) {
toast.error('양식을 선택해주세요.');
return;
}
// BUG #13 fix: 빈 폼 사전 검증 — 최소 1개 필드 입력 필요
if (!isEditMode) {
const formData = getFormData();
const dt = formData.basicInfo.documentType;
const hasDedicatedContent =
(dt === 'proposal' && formData.proposalData?.title) ||
(dt === 'expenseReport' && (formData.expenseReportData?.items?.length ?? 0) > 0) ||
(dt === 'expenseEstimate' && (formData.expenseEstimateData?.items?.length ?? 0) > 0);
const hasDynamicContent = formData.dynamicFormData && Object.values(formData.dynamicFormData).some(v => v !== '' && v !== null && v !== undefined && v !== 0);
const hasDedicatedFormContent = !isDedicatedForm(dt) ? false :
[formData.officialDocumentData, formData.resignationData, formData.employmentCertData,
formData.careerCertData, formData.appointmentCertData, formData.sealUsageData,
formData.leaveNotice1stData, formData.leaveNotice2ndData, formData.powerOfAttorneyData,
formData.boardMinutesData, formData.quotationData].some(d =>
d && Object.values(d as unknown as Record<string, unknown>).some(v => v !== '' && v !== null && v !== undefined && v !== 0)
);
if (!hasDedicatedContent && !hasDynamicContent && !hasDedicatedFormContent) {
toast.error('문서 내용을 최소 1개 이상 입력해주세요.');
return;
}
}
setIsSubmitting(true);
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 || '저장에 실패했습니다.');
// 수정 모드: 기존 문서 업데이트
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 }));
}
// BUG #11 fix: edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록
if (result.data?.id) {
setSavedDocId(String(result.data.id));
}
} 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 || '임시저장에 실패했습니다.');
}
toast.error(result.error || '임시저장에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Save draft error:', error);
toast.error('저장 중 오류가 발생했습니다.');
}
});
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Save draft error:', error);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
}, [getFormData, isEditMode, documentId]);
// 미리보기 핸들러
@@ -461,7 +617,7 @@ export function DocumentCreate() {
}, []);
// 미리보기용 데이터 변환
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData => {
const drafter = {
id: 'drafter-1',
name: basicInfo.drafter,
@@ -511,12 +667,70 @@ export function DocumentCreate() {
})),
cardInfo: expenseReportData.cardId || '-',
totalAmount: expenseReportData.totalAmount,
attachments: expenseReportData.attachments.map(f => f.name),
attachments: [
...(expenseReportData.uploadedFiles || []).map(f => `/api/proxy/files/${f.id}/download`),
...expenseReportData.attachments.map(f => URL.createObjectURL(f)),
],
approvers,
drafter,
};
default: {
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
// 신규 전용 폼들: DynamicDocumentData 형태로 미리보기
const dedicatedFormDataMap: Record<string, { formName: string; fields: Record<string, unknown> }> = {
officialDocument: { formName: '공문서', fields: officialDocumentData as unknown as Record<string, unknown> },
resignation: { formName: '사직서', fields: resignationData as unknown as Record<string, unknown> },
employmentCert: { formName: '재직증명서', fields: employmentCertData as unknown as Record<string, unknown> },
careerCert: { formName: '경력증명서', fields: careerCertData as unknown as Record<string, unknown> },
appointmentCert: { formName: '위촉증명서', fields: appointmentCertData as unknown as Record<string, unknown> },
sealUsage: { formName: '사용인감계', fields: sealUsageData as unknown as Record<string, unknown> },
leaveNotice1st: { formName: '연차촉진 1차', fields: leaveNotice1stData as unknown as Record<string, unknown> },
leaveNotice2nd: { formName: '연차촉진 2차', fields: leaveNotice2ndData as unknown as Record<string, unknown> },
powerOfAttorney: { formName: '위임장', fields: powerOfAttorneyData as unknown as Record<string, unknown> },
boardMinutes: { formName: '이사회의사록', fields: boardMinutesData as unknown as Record<string, unknown> },
quotation: { formName: '견적서', fields: quotationData as unknown as Record<string, unknown> },
};
const dedicatedPreview = dedicatedFormDataMap[basicInfo.documentType];
if (dedicatedPreview) {
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
formName: dedicatedPreview.formName,
formCategory: basicInfo.formCategory,
fields: filterVisibleFields(dedicatedPreview.fields),
fieldLabels: getFieldLabels(basicInfo.documentType),
approvers,
drafter,
};
}
// 동적 폼이면 DynamicDocumentData 반환
if (basicInfo.formId && !isDedicatedForm(basicInfo.documentType)) {
// 동적 폼 필드의 name → label 매핑 생성
const fieldLabels: Record<string, string> = {};
dynamicFormFields.forEach(f => {
fieldLabels[f.name] = f.label;
// array 타입의 columns도 매핑
if (f.columns) {
f.columns.forEach(col => {
fieldLabels[col.name] = col.label;
});
}
});
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
formName: basicInfo.formName || '문서',
formCategory: basicInfo.formCategory,
fields: dynamicFormData,
fieldLabels,
approvers,
drafter,
};
}
// 기본 품의서 폴백
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
@@ -537,7 +751,7 @@ export function DocumentCreate() {
};
}
}
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData, officialDocumentData, resignationData, employmentCertData, careerCertData, appointmentCertData, sealUsageData, leaveNotice1stData, leaveNotice2ndData, powerOfAttorneyData, boardMinutesData, quotationData, dynamicFormData, dynamicFormFields]);
// 문서 유형별 폼 렌더링
const renderDocumentTypeForm = () => {
@@ -548,11 +762,48 @@ export function DocumentCreate() {
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
case 'expenseEstimate':
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} isLoading={isLoadingEstimate} />;
// 신규 12종 전용 폼
case 'officialDocument':
return <OfficialDocumentForm data={officialDocumentData} onChange={setOfficialDocumentData} />;
case 'resignation':
return <ResignationForm data={resignationData} onChange={setResignationData} />;
case 'leaveNotice1st':
return <LeaveNotice1stForm data={leaveNotice1stData} onChange={setLeaveNotice1stData} />;
case 'leaveNotice2nd':
return <LeaveNotice2ndForm data={leaveNotice2ndData} onChange={setLeaveNotice2ndData} />;
case 'powerOfAttorney':
return <PowerOfAttorneyForm data={powerOfAttorneyData} onChange={setPowerOfAttorneyData} />;
case 'boardMinutes':
return <BoardMinutesForm data={boardMinutesData} onChange={setBoardMinutesData} />;
case 'employmentCert':
return <EmploymentCertForm data={employmentCertData} onChange={setEmploymentCertData} />;
case 'careerCert':
return <CareerCertForm data={careerCertData} onChange={setCareerCertData} />;
case 'appointmentCert':
return <AppointmentCertForm data={appointmentCertData} onChange={setAppointmentCertData} />;
case 'sealUsage':
return <SealUsageForm data={sealUsageData} onChange={setSealUsageData} />;
case 'quotation':
return <QuotationForm data={quotationData} onChange={setQuotationData} />;
default:
// 동적 폼: API template 기반 렌더링
if (basicInfo.formId) {
return (
<DynamicFormRenderer
formId={basicInfo.formId}
data={dynamicFormData}
onChange={setDynamicFormData}
onFieldsLoaded={setDynamicFormFields}
/>
);
}
return null;
}
};
// 양식이 선택되었는지 여부 (결재선/참조/폼 섹션 표시 조건)
const isFormSelected = !!basicInfo.formId;
// 현재 모드에 맞는 config 선택
const currentConfig = isEditMode
? documentEditConfig
@@ -562,11 +813,11 @@ export function DocumentCreate() {
// 헤더 액션 버튼 (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]);
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline', disabled: !isFormSelected },
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isSubmitting },
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isSubmitting || !canCreate || !isFormSelected, loading: isSubmitting },
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isSubmitting || !isFormSelected, loading: isSubmitting },
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isSubmitting, isEditMode, canCreate, canDelete, isFormSelected]);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => {
@@ -575,17 +826,22 @@ export function DocumentCreate() {
{/* 기본 정보 */}
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
{/* 결재선 */}
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
{/* 양식 선택 후에만 결재선/참조/폼 표시 */}
{isFormSelected && (
<>
{/* 결재선 */}
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
{/* 참조 */}
<ReferenceSection data={references} onChange={setReferences} />
{/* 참조 */}
<ReferenceSection data={references} onChange={setReferences} />
{/* 문서 유형별 폼 */}
{renderDocumentTypeForm()}
{/* 문서 유형별 폼 */}
{renderDocumentTypeForm()}
</>
)}
</div>
);
}, [basicInfo, approvalLine, references, renderDocumentTypeForm]);
}, [basicInfo, approvalLine, references, renderDocumentTypeForm, isFormSelected]);
return (
<>
@@ -602,7 +858,7 @@ export function DocumentCreate() {
<DocumentDetailModal
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
documentType={basicInfo.documentType as ModalDocumentType}
documentType={['proposal', 'expenseReport', 'expenseEstimate'].includes(basicInfo.documentType) ? basicInfo.documentType as ModalDocumentType : 'dynamic'}
data={getPreviewData()}
mode="draft"
documentStatus="draft"

View File

@@ -9,21 +9,46 @@ export interface UploadedFile {
mime_type?: string;
}
// 문서 유형
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
// 문서 유형 (기존 3종 + API 기반 확장)
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | string;
// 전용 폼이 있는 코드 (API code + frontend camelCase 모두 포함)
export const DEDICATED_FORM_CODES = [
// 기존 3종
'proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate',
// 일반
'official_letter', 'officialDocument',
// 인사/근태
'resignation',
'leave_promotion_1st', 'leaveNotice1st',
'leave_promotion_2nd', 'leaveNotice2nd',
'delegation', 'powerOfAttorney',
'board_minutes', 'boardMinutes',
// 증명서
'employment_cert', 'employmentCert',
'career_cert', 'careerCert',
'appointment_cert', 'appointmentCert',
'seal_usage', 'sealUsage',
// 재무
'quotation',
] as const;
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
{ value: 'proposal', label: '품의서' },
{ value: 'expenseReport', label: '지출결의서' },
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
{ value: 'expenseEstimate', label: '비용견적서' },
];
// 결재 단계 유형
export type StepType = 'approval' | 'agreement';
// 결재자/참조자 정보
export interface ApprovalPerson {
id: string;
department: string;
position: string;
name: string;
stepType?: StepType; // 결재(approval) 또는 합의(agreement)
}
// 기본 정보
@@ -34,6 +59,11 @@ export interface BasicInfo {
draftDate: string;
documentNo: string;
documentType: DocumentType;
formId?: number; // API 양식 ID
formCode?: string; // API 양식 코드
formName?: string; // API 양식 이름
formCategory?: string; // API 양식 카테고리
isUrgent?: boolean; // 긴급 여부
}
// 품의서 데이터
@@ -68,7 +98,7 @@ export interface ExpenseReportData {
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
}
// 지출 예상 내역서 항목
// 비용견적서 항목
export interface ExpenseEstimateItem {
id: string;
checked: boolean;
@@ -79,14 +109,198 @@ export interface ExpenseEstimateItem {
memo: string;
}
// 지출 예상 내역서 데이터
// 비용견적서 데이터
export interface ExpenseEstimateData {
title: string;
body: string;
items: ExpenseEstimateItem[];
totalExpense: number;
accountBalance: number;
finalDifference: number;
}
// ===== 신규 12종 전용 폼 데이터 =====
// 공문서
export interface OfficialDocumentData {
documentNumber: string;
documentDate: string;
recipient: string;
reference: string;
title: string;
body: string;
attachment: string;
companyName: string;
representativeName: string;
address: string;
phone: string;
fax: string;
email: string;
}
// 사직서
export interface ResignationData {
employeeId: string;
department: string;
position: string;
employeeName: string;
residentNumber: string;
joinDate: string;
resignDate: string;
employeeAddress: string;
reasonType: string;
reasonDetail: string;
submitDate: string;
}
// 재직증명서
export interface EmploymentCertData {
employeeId: string;
employeeName: string;
residentNumber: string;
employeeAddress: string;
companyName: string;
businessNumber: string;
department: string;
position: string;
employmentPeriodStart: string;
employmentPeriodEnd: string;
purpose: string;
issueDate: string;
}
// 경력증명서
export interface CareerCertData {
employeeId: string;
employeeName: string;
residentNumber: string;
birthDate: string;
employeeAddress: string;
companyName: string;
businessNumber: string;
representativeName: string;
companyPhone: string;
companyAddress: string;
department: string;
positionTitle: string;
workPeriodStart: string;
workPeriodEnd: string;
duties: string;
purpose: string;
issueDate: string;
}
// 위촉증명서
export interface AppointmentCertData {
employeeId: string;
employeeName: string;
residentNumber: string;
department: string;
phone: string;
appointmentPeriodStart: string;
appointmentPeriodEnd: string;
contractQualification: string;
purpose: string;
issueDate: string;
}
// 사용인감계
export interface SealUsageData {
sealImprint: string;
purpose: string;
submitTo: string;
attachedDocuments: string;
usageDate: string;
companyName: string;
businessNumber: string;
address: string;
representativeName: string;
}
// 연차촉진 1차
export interface LeaveNotice1stData {
employeeId: string;
department: string;
position: string;
totalDays: number;
usedDays: number;
remainingDays: number;
deadline: string;
legalNotice: string;
}
// 연차촉진 2차
export interface LeaveNotice2ndData {
employeeId: string;
department: string;
position: string;
remainingDays: number;
designatedDates: string[];
legalNotice: string;
}
// 위임장
export interface PowerOfAttorneyData {
principalCompanyName: string;
principalBusinessNumber: string;
principalAddress: string;
principalRepresentative: string;
agentName: string;
agentBirthDate: string;
agentAddress: string;
agentPhone: string;
agentDepartment: string;
delegationDetails: string;
delegationPeriodStart: string;
delegationPeriodEnd: string;
attachedDocuments: string;
}
// 이사회의사록
export interface BoardMinutesData {
meetingDate: string;
meetingPlace: string;
totalDirectors: number;
attendingDirectors: number;
totalAuditors: number;
attendingAuditors: number;
agendaTitle: string;
agendaResult: string;
chairperson: string;
proceedings: string;
adjournmentTime: string;
signatories: string;
}
// 견적서 항목
export interface QuotationItem {
id: string;
name: string;
specification: string;
quantity: number;
unitPrice: number;
supplyAmount: number;
tax: number;
note: string;
}
// 견적서
export interface QuotationData {
recipientName: string;
quotationDate: string;
businessNumber: string;
companyName: string;
representativeName: string;
companyAddress: string;
businessType: string;
businessCategory: string;
companyPhone: string;
accountNumber: string;
autoTax: boolean;
items: QuotationItem[];
specialNotes: string;
}
// 전체 문서 데이터
export interface DocumentFormData {
basicInfo: BasicInfo;
@@ -95,6 +309,18 @@ export interface DocumentFormData {
proposalData?: ProposalData;
expenseReportData?: ExpenseReportData;
expenseEstimateData?: ExpenseEstimateData;
officialDocumentData?: OfficialDocumentData;
resignationData?: ResignationData;
employmentCertData?: EmploymentCertData;
careerCertData?: CareerCertData;
appointmentCertData?: AppointmentCertData;
sealUsageData?: SealUsageData;
leaveNotice1stData?: LeaveNotice1stData;
leaveNotice2ndData?: LeaveNotice2ndData;
powerOfAttorneyData?: PowerOfAttorneyData;
boardMinutesData?: BoardMinutesData;
quotationData?: QuotationData;
dynamicFormData?: Record<string, unknown>; // 동적 폼 데이터 (API template 기반)
}
// 카드 옵션

View File

@@ -5,12 +5,14 @@ import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
import { LinkedDocumentContent } from './LinkedDocumentContent';
import { DynamicDocument } from './DynamicDocument';
import type {
DocumentDetailModalProps,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
DynamicDocumentData,
} from './types';
/**
@@ -41,9 +43,11 @@ export function DocumentDetailModalV2({
case 'expenseReport':
return '지출결의서';
case 'expenseEstimate':
return '지출 예상 내역서';
return '비용견적서';
case 'document':
return (data as LinkedDocumentData).templateName || '문서 결재';
case 'dynamic':
return (data as DynamicDocumentData).formName || '문서';
default:
return '문서';
}
@@ -74,6 +78,8 @@ export function DocumentDetailModalV2({
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
case 'document':
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
case 'dynamic':
return <DynamicDocument data={data as DynamicDocumentData} />;
default:
return null;
}

View File

@@ -0,0 +1,128 @@
'use client';
/**
* 동적 양식 문서 컴포넌트
*
* API template 기반 동적 폼 데이터를 문서 형태로 렌더링
*/
import { ApprovalLineBox } from './ApprovalLineBox';
import type { DynamicDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { formatNumber } from '@/lib/utils/amount';
interface DynamicDocumentProps {
data: DynamicDocumentData;
}
export function DynamicDocument({ data }: DynamicDocumentProps) {
const fieldEntries = Object.entries(data.fields).filter(
([, value]) => value !== undefined && value !== null && value !== ''
);
const labels = data.fieldLabels || {};
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<DocumentHeader
title={data.formName}
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 내용 */}
<div className="border border-gray-300">
{fieldEntries.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400">
.
</div>
) : (
fieldEntries.map(([key, value]) => (
<div key={key} className="flex border-b border-gray-300 last:border-b-0">
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300 shrink-0">
{labels[key] || key}
</div>
<div className="flex-1 p-3 text-sm">
{renderFieldValue(value, labels)}
</div>
</div>
))
)}
</div>
</div>
);
}
function renderFieldValue(value: unknown, labels?: Record<string, string>): React.ReactNode {
if (value === null || value === undefined) return '-';
// 숫자
if (typeof value === 'number') {
return formatNumber(value);
}
// 문자열
if (typeof value === 'string') {
return value || '-';
}
// 불리언
if (typeof value === 'boolean') {
return value ? '예' : '아니오';
}
// 배열 (테이블 데이터)
if (Array.isArray(value)) {
if (value.length === 0) return '-';
// 객체 배열이면 테이블로 렌더링
if (typeof value[0] === 'object' && value[0] !== null) {
const columns = Object.keys(value[0] as Record<string, unknown>);
return (
<table className="w-full border text-xs">
<thead>
<tr className="bg-gray-50">
<th className="border px-2 py-1 w-10">No</th>
{columns.map((col) => (
<th key={col} className="border px-2 py-1">{labels?.[col] || col}</th>
))}
</tr>
</thead>
<tbody>
{value.map((row, idx) => (
<tr key={idx}>
<td className="border px-2 py-1 text-center">{idx + 1}</td>
{columns.map((col) => (
<td key={col} className="border px-2 py-1">
{formatCellValue((row as Record<string, unknown>)[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// 단순 배열
return value.join(', ');
}
// 날짜 범위 객체
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, unknown>;
if ('start' in obj && 'end' in obj) {
return `${obj.start || '-'} ~ ${obj.end || '-'}`;
}
return JSON.stringify(value);
}
return String(value);
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'number') return formatNumber(value);
return String(value);
}

View File

@@ -1,7 +1,7 @@
'use client';
/**
* 지출 예상 내역서 문서 컴포넌트
* 비용견적서 문서 컴포넌트
*
* 공통 컴포넌트 사용:
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
@@ -42,7 +42,7 @@ export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps)
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="지출 예상 내역서"
title="비용견적서"
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
@@ -50,9 +50,9 @@ export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps)
{/* 문서 내용 */}
<div className="border border-gray-300">
{/* 지출 예상 내역서 헤더 */}
{/* 비용견적서 헤더 */}
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
{/* 테이블 */}

View File

@@ -119,7 +119,7 @@ export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
key={index}
src={url}
alt={`첨부 이미지 ${index + 1}`}
className="w-full h-32 object-cover rounded border"
className="w-full max-h-48 object-contain rounded border"
/>
))}
</div>

View File

@@ -97,7 +97,7 @@ export function ProposalDocument({ data }: ProposalDocumentProps) {
key={index}
src={url}
alt={`첨부 이미지 ${index + 1}`}
className="w-full h-32 object-cover rounded border"
className="w-full max-h-48 object-contain rounded border"
/>
))}
</div>

View File

@@ -0,0 +1,292 @@
/**
* 전용 양식별 한글 필드 라벨 매핑
*
* DynamicDocument에서 영문 필드키 대신 한글 라벨을 표시하기 위한 공통 모듈.
* DocumentCreate의 getPreviewData()와 각 Box의 convertToModalData()에서 공유.
*/
// 미리보기에서 숨길 내부 필드 (ID, 시스템 값)
const HIDDEN_FIELDS = new Set([
'employeeId',
'vendorId',
'autoTax',
]);
/** 공문서 */
const officialDocumentLabels: Record<string, string> = {
documentNumber: '문서번호',
documentDate: '문서일자',
recipient: '수신',
reference: '참조',
title: '제목',
body: '본문',
attachment: '붙임',
companyName: '회사명',
representativeName: '대표자명',
address: '주소',
phone: '전화',
fax: '팩스',
email: '이메일',
};
/** 사직서 */
const resignationLabels: Record<string, string> = {
department: '부서',
position: '직위',
employeeName: '성명',
residentNumber: '주민등록번호',
joinDate: '입사일',
resignDate: '퇴사일',
employeeAddress: '주소',
reasonType: '사직사유',
reasonDetail: '사유상세',
submitDate: '제출일',
};
/** 재직증명서 */
const employmentCertLabels: Record<string, string> = {
employeeName: '성명',
residentNumber: '주민등록번호',
employeeAddress: '주소',
companyName: '회사명',
businessNumber: '사업자등록번호',
department: '부서',
position: '직위',
employmentPeriodStart: '재직기간 시작',
employmentPeriodEnd: '재직기간 종료',
purpose: '용도',
issueDate: '발급일',
};
/** 경력증명서 */
const careerCertLabels: Record<string, string> = {
employeeName: '성명',
residentNumber: '주민등록번호',
birthDate: '생년월일',
employeeAddress: '주소',
companyName: '회사명',
businessNumber: '사업자등록번호',
representativeName: '대표자명',
companyPhone: '회사전화',
companyAddress: '회사주소',
department: '부서',
positionTitle: '직위/직책',
workPeriodStart: '근무기간 시작',
workPeriodEnd: '근무기간 종료',
duties: '담당업무',
purpose: '용도',
issueDate: '발급일',
};
/** 위촉증명서 */
const appointmentCertLabels: Record<string, string> = {
employeeName: '성명',
residentNumber: '주민등록번호',
department: '부서',
phone: '연락처',
appointmentPeriodStart: '위촉기간 시작',
appointmentPeriodEnd: '위촉기간 종료',
contractQualification: '계약자격',
purpose: '용도',
issueDate: '발급일',
};
/** 사용인감계 */
const sealUsageLabels: Record<string, string> = {
sealImprint: '인감날인',
purpose: '사용목적',
submitTo: '제출처',
attachedDocuments: '첨부서류',
usageDate: '사용일',
companyName: '회사명',
businessNumber: '사업자등록번호',
address: '주소',
representativeName: '대표자명',
};
/** 연차촉진 1차 */
const leaveNotice1stLabels: Record<string, string> = {
department: '부서',
position: '직위',
totalDays: '총 연차일수',
usedDays: '사용일수',
remainingDays: '잔여일수',
deadline: '사용기한',
legalNotice: '법적고지',
};
/** 연차촉진 2차 */
const leaveNotice2ndLabels: Record<string, string> = {
department: '부서',
position: '직위',
remainingDays: '잔여일수',
designatedDates: '지정일자',
legalNotice: '법적고지',
};
/** 위임장 */
const powerOfAttorneyLabels: Record<string, string> = {
principalCompanyName: '위임자 회사명',
principalBusinessNumber: '위임자 사업자등록번호',
principalAddress: '위임자 주소',
principalRepresentative: '위임자 대표자',
agentName: '대리인 성명',
agentBirthDate: '대리인 생년월일',
agentAddress: '대리인 주소',
agentPhone: '대리인 연락처',
agentDepartment: '대리인 소속부서',
delegationDetails: '위임사항',
delegationPeriodStart: '위임기간 시작',
delegationPeriodEnd: '위임기간 종료',
attachedDocuments: '첨부서류',
};
/** 이사회의사록 */
const boardMinutesLabels: Record<string, string> = {
meetingDate: '회의일시',
meetingPlace: '회의장소',
totalDirectors: '이사 총원',
attendingDirectors: '출석 이사',
totalAuditors: '감사 총원',
attendingAuditors: '출석 감사',
agendaTitle: '의안',
agendaResult: '의결결과',
chairperson: '의장',
proceedings: '회의내용',
adjournmentTime: '폐회시간',
signatories: '서명인',
};
/** 견적서 */
const quotationLabels: Record<string, string> = {
recipientName: '수신',
quotationDate: '견적일자',
businessNumber: '사업자등록번호',
companyName: '회사명',
representativeName: '대표자명',
companyAddress: '회사주소',
businessType: '업태',
businessCategory: '종목',
companyPhone: '전화',
accountNumber: '계좌번호',
items: '견적항목',
specialNotes: '특기사항',
};
// 동적 폼: 근태신청
const attendanceRequestLabels: Record<string, string> = {
user_name: '신청자',
request_type: '신청유형',
period: '기간',
days: '일수',
reason: '사유',
};
// 동적 폼: 사유서
const reasonReportLabels: Record<string, string> = {
user_name: '작성자',
report_type: '사유유형',
target_date: '대상일',
reason: '사유',
};
// 동적 폼: 품의서 (proposal은 전용 폼이지만 content 키가 영문일 수 있음)
const proposalLabels: Record<string, string> = {
title: '제목',
vendor: '거래처',
description: '내용',
reason: '사유',
estimatedCost: '예상비용',
};
/** 양식코드 → 한글 라벨 맵 */
const FORM_FIELD_LABELS: Record<string, Record<string, string>> = {
attendance_request: attendanceRequestLabels,
reason_report: reasonReportLabels,
proposal: proposalLabels,
officialDocument: officialDocumentLabels,
official_letter: officialDocumentLabels,
resignation: resignationLabels,
employmentCert: employmentCertLabels,
employment_cert: employmentCertLabels,
careerCert: careerCertLabels,
career_cert: careerCertLabels,
appointmentCert: appointmentCertLabels,
appointment_cert: appointmentCertLabels,
sealUsage: sealUsageLabels,
seal_usage: sealUsageLabels,
leaveNotice1st: leaveNotice1stLabels,
leave_promotion_1st: leaveNotice1stLabels,
leaveNotice2nd: leaveNotice2ndLabels,
leave_promotion_2nd: leaveNotice2ndLabels,
powerOfAttorney: powerOfAttorneyLabels,
delegation: powerOfAttorneyLabels,
boardMinutes: boardMinutesLabels,
board_minutes: boardMinutesLabels,
quotation: quotationLabels,
};
/** 양식코드 → 한글 양식명 */
const FORM_NAME_MAP: Record<string, string> = {
attendance_request: '근태신청',
reason_report: '사유서',
proposal: '품의서',
officialDocument: '공문서',
official_letter: '공문서',
resignation: '사직서',
employmentCert: '재직증명서',
employment_cert: '재직증명서',
careerCert: '경력증명서',
career_cert: '경력증명서',
appointmentCert: '위촉증명서',
appointment_cert: '위촉증명서',
sealUsage: '사용인감계',
seal_usage: '사용인감계',
leaveNotice1st: '연차촉진 1차',
leave_promotion_1st: '연차촉진 1차',
leaveNotice2nd: '연차촉진 2차',
leave_promotion_2nd: '연차촉진 2차',
powerOfAttorney: '위임장',
delegation: '위임장',
boardMinutes: '이사회의사록',
board_minutes: '이사회의사록',
quotation: '견적서',
expenseReport: '지출결의서',
expense_report: '지출결의서',
expenseEstimate: '비용견적서',
expense_estimate: '비용견적서',
};
/**
* 양식코드에 해당하는 필드 라벨 맵 반환
*/
export function getFieldLabels(formCode: string): Record<string, string> | undefined {
return FORM_FIELD_LABELS[formCode];
}
/**
* 양식코드에 해당하는 한글 양식명 반환
*/
export function getFormName(formCode: string): string {
return FORM_NAME_MAP[formCode] || formCode;
}
/**
* 미리보기에서 숨길 내부 필드인지 확인
*/
export function isHiddenField(key: string): boolean {
return HIDDEN_FIELDS.has(key);
}
/**
* fields에서 숨길 필드를 제거한 새 객체 반환
*/
export function filterVisibleFields(fields: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (!HIDDEN_FIELDS.has(key)) {
result[key] = value;
}
}
return result;
}

View File

@@ -47,7 +47,7 @@ export function DocumentDetailModal({
case 'expenseReport':
return '지출결의서';
case 'expenseEstimate':
return '지출 예상 내역서';
return '비용견적서';
default:
return '문서';
}
@@ -179,4 +179,5 @@ export { ExpenseReportDocument } from './ExpenseReportDocument';
export { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
// V2 - DocumentViewer 기반
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
export { DynamicDocument } from './DynamicDocument';

View File

@@ -1,6 +1,6 @@
// ===== 문서 상세 모달 타입 정의 =====
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document' | 'dynamic';
// 결재자 정보
export interface Approver {
@@ -8,7 +8,7 @@ export interface Approver {
name: string;
position: string;
department: string;
status: 'pending' | 'approved' | 'rejected' | 'none';
status: 'pending' | 'approved' | 'rejected' | 'skipped' | 'on_hold' | 'none';
approvedAt?: string;
}
@@ -50,7 +50,7 @@ export interface ExpenseReportDocumentData {
drafter: Approver;
}
// 지출 예상 내역서 항목
// 비용견적서 항목
export interface ExpenseEstimateItem {
id: string;
expectedPaymentDate: string;
@@ -60,7 +60,7 @@ export interface ExpenseEstimateItem {
account: string;
}
// 지출 예상 내역서 데이터
// 비용견적서 데이터
export interface ExpenseEstimateDocumentData {
documentNo: string;
createdAt: string;
@@ -72,6 +72,18 @@ export interface ExpenseEstimateDocumentData {
drafter: Approver;
}
// 동적 양식 문서 데이터 (API template 기반)
export interface DynamicDocumentData {
documentNo: string;
createdAt: string;
formName: string;
formCategory?: string;
fields: Record<string, unknown>;
fieldLabels?: Record<string, string>;
approvers: Approver[];
drafter: Approver;
}
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
export interface LinkedDocumentData {
documentNo: string;
@@ -96,22 +108,26 @@ export interface LinkedDocumentData {
}
// 문서 상세 모달 모드
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference' | 'completed';
// 문서 상태 (기안함 기준)
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected';
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
// 문서 상세 모달 Props
export interface DocumentDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentType;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | DynamicDocumentData;
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려), 'completed': 완료함
documentStatus?: DocumentStatus; // 문서 상태
onEdit?: () => void;
onCopy?: () => void;
onApprove?: () => void;
onReject?: () => void;
onSubmit?: () => void; // 상신 콜백
onHold?: () => void; // 보류
onReleaseHold?: () => void; // 보류해제
onPreDecide?: () => void; // 전결
onCancel?: () => void; // 회수
}

View File

@@ -85,6 +85,8 @@ function mapApiStatus(apiStatus: string): DocumentStatus {
'in_progress': 'inProgress',
'approved': 'approved',
'rejected': 'rejected',
'cancelled': 'cancelled',
'on_hold': 'on_hold',
};
return statusMap[apiStatus] || 'draft';
}
@@ -97,6 +99,8 @@ function mapApproverStatus(stepStatus: string): Approver['status'] {
'pending': 'pending',
'approved': 'approved',
'rejected': 'rejected',
'skipped': 'skipped',
'on_hold': 'on_hold',
};
return statusMap[stepStatus] || 'none';
}
@@ -156,7 +160,7 @@ function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
const DRAFT_STATUS_MAP: Record<string, string> = {
'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress',
'approved': 'approved', 'rejected': 'rejected',
'approved': 'approved', 'rejected': 'rejected', 'cancelled': 'cancelled', 'on_hold': 'on_hold',
};
// ============================================
@@ -255,3 +259,12 @@ export async function cancelDraft(id: string): Promise<ActionResult> {
errorMessage: '결재 회수에 실패했습니다.',
});
}
export async function copyApproval(id: string): Promise<ActionResult<{ id: number }>> {
return executeServerAction<{ id: number }>({
url: buildApiUrl(`/api/v1/approvals/${id}/copy`),
method: 'POST',
body: {},
errorMessage: '문서 복사에 실패했습니다.',
});
}

View File

@@ -25,13 +25,6 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
@@ -45,7 +38,9 @@ import type {
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
DynamicDocumentData,
} from '@/components/approval/DocumentDetail/types';
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
import type {
DraftRecord,
Approver,
@@ -319,21 +314,18 @@ export function DraftBox() {
// ===== 문서 타입 판별 =====
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 code = item.documentTypeCode;
if (code === 'expenseEstimate' || code === 'expense_estimate') return 'expenseEstimate';
if (code === 'expenseReport' || code === 'expense_report') return 'expenseReport';
if (code === 'proposal') return 'proposal';
// 14개 전용 양식 + 동적 양식 → 'dynamic'
return 'dynamic';
};
// ===== 모달용 데이터 변환 =====
const convertToModalData = (
item: DraftRecord
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData => {
const docType = getDocumentType(item);
const content = item.content || {};
@@ -412,7 +404,20 @@ export function DraftBox() {
drafter,
};
}
case 'dynamic': {
// 14개 전용 양식 + 동적 양식: DynamicDocumentData
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
formName: item.documentType || getFormName(item.documentTypeCode),
fields: filterVisibleFields(content),
fieldLabels: getFieldLabels(item.documentTypeCode),
approvers,
drafter,
};
}
default: {
// 품의서 (proposal)
const files =
(content.files as Array<{ id: number; name: string; url?: string }>) || [];
const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`);
@@ -585,42 +590,6 @@ export function DraftBox() {
</>
),
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;

View File

@@ -3,10 +3,10 @@
*/
// ===== 문서 상태 =====
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
// ===== 필터 옵션 =====
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'all', label: '전체' },
@@ -15,6 +15,8 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'inProgress', label: '진행중' },
{ value: 'approved', label: '완료' },
{ value: 'rejected', label: '반려' },
{ value: 'cancelled', label: '회수' },
{ value: 'on_hold', label: '보류' },
];
// ===== 정렬 옵션 =====
@@ -33,7 +35,7 @@ export interface Approver {
name: string;
position: string;
department: string;
status: 'pending' | 'approved' | 'rejected' | 'none';
status: 'pending' | 'approved' | 'rejected' | 'skipped' | 'on_hold' | 'none';
approvedAt?: string;
}
@@ -63,6 +65,8 @@ export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
inProgress: '진행중',
approved: '완료',
rejected: '반려',
cancelled: '회수',
on_hold: '보류',
};
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
@@ -71,6 +75,8 @@ export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
inProgress: 'bg-blue-100 text-blue-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-orange-100 text-orange-800',
on_hold: 'bg-purple-100 text-purple-800',
};
export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
@@ -78,6 +84,8 @@ export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
skipped: 'bg-gray-100 text-gray-500',
on_hold: 'bg-purple-100 text-purple-800',
};
// ===== 기안함 현황 통계 =====

View File

@@ -50,6 +50,7 @@ function mapApiStatus(apiStatus: string): DocumentStatus {
const statusMap: Record<string, DocumentStatus> = {
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
'approved': 'approved', 'rejected': 'rejected',
'cancelled': 'cancelled', 'on_hold': 'on_hold',
};
return statusMap[apiStatus] || 'pending';
}
@@ -70,6 +71,8 @@ function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
id: String(data.id),
documentNo: data.document_number,
approvalType: mapApprovalType(data.form?.category),
formCode: data.form?.code,
formName: data.form?.name,
title: data.title,
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
drafter: data.drafter?.name || '',

View File

@@ -19,13 +19,6 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
@@ -36,7 +29,10 @@ import {
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types';
import type {
ReferenceTabType,
ReferenceRecord,
@@ -82,7 +78,10 @@ export function ReferenceBox() {
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [isModalLoading, setIsModalLoading] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
// API 데이터
const [data, setData] = useState<ReferenceRecord[]>([]);
@@ -284,86 +283,125 @@ export function ReferenceBox() {
}, [selectedItems, loadData, loadSummary]);
// ===== 문서 클릭/상세 보기 핸들러 =====
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
const handleDocumentClick = useCallback(async (item: ReferenceRecord) => {
setSelectedDocument(item);
setIsModalLoading(true);
setIsModalOpen(true);
try {
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
const docTypeCode = formData.basicInfo.documentType;
const drafter = {
id: 'drafter-1',
name: formData.basicInfo.drafter,
position: formData.basicInfo.drafterPosition || '',
department: formData.basicInfo.drafterDepartment || '',
status: 'approved' as const,
};
const approvers = formData.approvalLine.map((person, index) => ({
id: person.id,
name: person.name,
position: person.position,
department: person.department,
status: index === 0 ? ('approved' as const) : ('none' as const),
}));
// 전용 양식 또는 동적 양식 → DynamicDocumentData
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
if (!isBuiltin) {
const dedicatedDataMap: Record<string, unknown> = {
officialDocument: formData.officialDocumentData,
resignation: formData.resignationData,
employmentCert: formData.employmentCertData,
careerCert: formData.careerCertData,
appointmentCert: formData.appointmentCertData,
sealUsage: formData.sealUsageData,
leaveNotice1st: formData.leaveNotice1stData,
leaveNotice2nd: formData.leaveNotice2ndData,
powerOfAttorney: formData.powerOfAttorneyData,
boardMinutes: formData.boardMinutesData,
quotation: formData.quotationData,
};
const dedicatedData = dedicatedDataMap[docTypeCode];
const fields = dedicatedData
? filterVisibleFields(dedicatedData as Record<string, unknown>)
: (formData.dynamicFormData || {});
setModalDocType('dynamic');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
formName: formData.basicInfo.formName || getFormName(docTypeCode),
fields,
fieldLabels: getFieldLabels(docTypeCode),
approvers,
drafter,
});
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
setModalDocType('expenseEstimate');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
items: formData.expenseEstimateData?.items.map(i => ({
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
amount: i.amount, vendor: i.vendor, account: i.memo || '',
})) || [],
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
approvers, drafter,
});
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
setModalDocType('expenseReport');
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
requestDate: formData.expenseReportData?.requestDate || '',
paymentDate: formData.expenseReportData?.paymentDate || '',
items: formData.expenseReportData?.items.map((i, idx) => ({
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
})) || [],
cardInfo: formData.expenseReportData?.cardId || '-',
totalAmount: formData.expenseReportData?.totalAmount || 0,
attachments: [], approvers, drafter,
});
} else {
// 품의서
setModalDocType('proposal');
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
setModalData({
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
vendor: formData.proposalData?.vendor || '-',
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
title: formData.proposalData?.title || item.title,
description: formData.proposalData?.description || '-',
reason: formData.proposalData?.reason || '-',
estimatedCost: formData.proposalData?.estimatedCost || 0,
attachments: uploadedFileUrls,
approvers, drafter,
});
}
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document:', error);
toast.error('문서를 불러오는데 실패했습니다.');
setIsModalOpen(false);
} finally {
setIsModalLoading(false);
}
}, []);
// ===== ApprovalType → DocumentType 변환 =====
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
switch (approvalType) {
case 'expense_estimate': return 'expenseEstimate';
case 'expense_report': return 'expenseReport';
default: return 'proposal';
}
};
// ===== ReferenceRecord → 모달용 데이터 변환 =====
const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
const docType = getDocumentType(item.approvalType);
const drafter = {
id: 'drafter-1',
name: item.drafter,
position: item.drafterPosition,
department: item.drafterDepartment,
status: 'approved' as const,
};
const approvers = [{
id: 'approver-1',
name: '결재자',
position: '부장',
department: '경영지원팀',
status: 'approved' as const,
}];
switch (docType) {
case 'expenseEstimate':
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
items: [
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
],
totalExpense: 3050000,
accountBalance: 25000000,
finalDifference: 21950000,
approvers,
drafter,
};
case 'expenseReport':
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
requestDate: item.draftDate,
paymentDate: item.draftDate,
items: [
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
],
cardInfo: '삼성카드 **** 1234',
totalAmount: 80000,
attachments: [],
approvers,
drafter,
};
default:
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
vendor: '거래처',
vendorPaymentDate: item.draftDate,
title: item.title,
description: item.title,
reason: '업무상 필요',
estimatedCost: 1000000,
attachments: [],
approvers,
drafter,
};
}
};
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => [
{ label: '전체', value: `${stats.all}`, icon: Files, iconColor: 'text-blue-500' },
@@ -390,39 +428,6 @@ export function ReferenceBox() {
{ key: 'status', label: '상태', className: 'text-center' },
], []);
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
const tableHeaderActions = useMemo(() => (
<div className="flex items-center gap-2">
{/* 필터 셀렉트박스 */}
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
<SelectTrigger className="min-w-[140px] w-auto">
<SelectValue placeholder="필터 선택" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 셀렉트박스 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="min-w-[140px] w-auto">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
), [filterOption, sortOption]);
// ===== UniversalListPage 설정 =====
const referenceBoxConfig: UniversalListConfig<ReferenceRecord> = useMemo(() => ({
title: '참조함',
@@ -475,8 +480,6 @@ export function ReferenceBox() {
itemsPerPage: itemsPerPage,
tableHeaderActions: tableHeaderActions,
// 모바일 필터 설정
filterConfig: [
{
@@ -619,12 +622,15 @@ export function ReferenceBox() {
/>
{/* 문서 상세 모달 */}
{selectedDocument && (
{selectedDocument && modalData && (
<DocumentDetailModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
documentType={getDocumentType(selectedDocument.approvalType)}
data={convertToModalData(selectedDocument)}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setModalData(null);
}}
documentType={modalDocType}
data={modalData}
mode="reference"
/>
)}
@@ -640,7 +646,8 @@ export function ReferenceBox() {
statCards,
startDate,
endDate,
tableHeaderActions,
filterOption,
sortOption,
handleMarkReadClick,
handleMarkUnreadClick,
handleDocumentClick,
@@ -651,8 +658,8 @@ export function ReferenceBox() {
handleMarkUnreadConfirm,
selectedDocument,
isModalOpen,
getDocumentType,
convertToModalData,
modalData,
modalDocType,
]);
// 모바일 필터 변경 핸들러

View File

@@ -9,11 +9,11 @@ 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 DocumentStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
// 필터 옵션
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
@@ -22,7 +22,7 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'expense_report', label: '지출결의서' },
{ value: 'proposal', label: '품의서' },
{ value: 'expense_estimate', label: '지출예상내역서' },
{ value: 'expense_estimate', label: '비용견적서' },
];
// 정렬 옵션
@@ -40,6 +40,8 @@ export interface ReferenceRecord {
id: string;
documentNo: string; // 문서번호
approvalType: ApprovalType; // 문서유형
formCode?: string; // 양식코드 (official_letter, resignation 등)
formName?: string; // 양식명 (공문서, 사직서 등)
title: string; // 제목
draftDate: string; // 기안일시
drafter: string; // 기안자
@@ -63,19 +65,23 @@ export const REFERENCE_TAB_LABELS: Record<ReferenceTabType, string> = {
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
expense_report: '지출결의서',
proposal: '품의서',
expense_estimate: '지출예상내역서',
expense_estimate: '비용견적서',
};
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
pending: '진행중',
approved: '완료',
rejected: '반려',
cancelled: '회수',
on_hold: '보류',
};
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',
cancelled: 'bg-orange-100 text-orange-800',
on_hold: 'bg-purple-100 text-purple-800',
};
export const READ_STATUS_LABELS: Record<ReadStatus, string> = {