feat(WEB): 결재함 문서 상세 모달 데이터 연동 개선

- ApprovalBox: 문서 클릭 시 API 데이터 로드하여 모달에 표시
- DocumentCreate: 품의서 폼 개선 및 actions 수정
- 결재자 정보 (직책, 부서) 표시 개선
This commit is contained in:
2026-01-22 23:19:37 +09:00
parent 2d3a7064e3
commit c8890c1a85
6 changed files with 213 additions and 85 deletions

View File

@@ -139,8 +139,8 @@ function mapDocumentStatus(status: string): string {
* API 데이터 → 프론트엔드 데이터 변환
*/
function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
// 현재 사용자의 결재 단계 정보 추출
const currentStep = data.steps?.find(s => s.step_type === 'approval');
// 현재 사용자의 결재 단계 정보 추출 ('approval' 또는 'agreement' 타입)
const currentStep = data.steps?.find(s => s.step_type === 'approval' || s.step_type === 'agreement');
const approver = currentStep?.approver;
const stepStatus = currentStep?.status || 'pending';

View File

@@ -20,6 +20,7 @@ import {
approveDocumentsBulk,
rejectDocumentsBulk,
} from './actions';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
@@ -109,6 +110,8 @@ export function ApprovalBox() {
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
const [isModalLoading, setIsModalLoading] = useState(false);
// API 데이터
const [data, setData] = useState<ApprovalRecord[]>([]);
@@ -274,9 +277,118 @@ export function ApprovalBox() {
}, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]);
// ===== 문서 클릭 핸들러 =====
const handleDocumentClick = useCallback((item: ApprovalRecord) => {
const handleDocumentClick = useCallback(async (item: ApprovalRecord) => {
setSelectedDocument(item);
setIsModalLoading(true);
setIsModalOpen(true);
try {
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
const docType = getDocumentType(item.approvalType);
// 기안자 정보
const drafter = {
id: 'drafter-1',
name: formData.basicInfo.drafter,
position: formData.basicInfo.drafterPosition || '',
department: formData.basicInfo.drafterDepartment || '',
status: 'approved' as const,
};
// 결재자 정보
const approvers = formData.approvalLine.map((person, index) => ({
id: person.id,
name: person.name,
position: person.position,
department: person.department,
status:
item.status === 'approved'
? ('approved' as const)
: item.status === 'rejected'
? ('rejected' as const)
: index === 0
? ('pending' as const)
: ('none' as const),
}));
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
switch (docType) {
case 'expenseEstimate':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
items: formData.expenseEstimateData?.items.map(item => ({
id: item.id,
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
amount: item.amount,
vendor: item.vendor,
account: item.memo || '',
})) || [],
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
approvers,
drafter,
};
break;
case 'expenseReport':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
requestDate: formData.expenseReportData?.requestDate || '',
paymentDate: formData.expenseReportData?.paymentDate || '',
items: formData.expenseReportData?.items.map((item, index) => ({
id: item.id,
no: index + 1,
description: item.description,
amount: item.amount,
note: item.note,
})) || [],
cardInfo: formData.expenseReportData?.cardId || '-',
totalAmount: formData.expenseReportData?.totalAmount || 0,
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
approvers,
drafter,
};
break;
default:
// 품의서
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
vendor: formData.proposalData?.vendor || '-',
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
title: formData.proposalData?.title || item.title,
description: formData.proposalData?.description || '-',
reason: formData.proposalData?.reason || '-',
estimatedCost: formData.proposalData?.estimatedCost || 0,
attachments: uploadedFileUrls,
approvers,
drafter,
};
break;
}
setModalData(convertedData);
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document:', error);
toast.error('문서를 불러오는데 실패했습니다.');
setIsModalOpen(false);
} finally {
setIsModalLoading(false);
}
}, []);
const handleModalEdit = useCallback(() => {
@@ -333,77 +445,7 @@ export function ApprovalBox() {
return 'proposal';
}
};
// ===== 모달용 데이터 변환 =====
const convertToModalData = (
item: ApprovalRecord
): 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: item.approver || '미지정',
position: '부장',
department: '경영지원팀',
status:
item.status === 'approved'
? ('approved' as const)
: item.status === 'rejected'
? ('rejected' as const)
: ('pending' as const),
},
];
switch (docType) {
case 'expenseEstimate':
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
items: [],
totalExpense: 0,
accountBalance: 0,
finalDifference: 0,
approvers,
drafter,
};
case 'expenseReport':
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
requestDate: item.draftDate,
paymentDate: item.draftDate,
items: [],
cardInfo: '',
totalAmount: 0,
attachments: [],
approvers,
drafter,
};
default:
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
vendor: '거래처',
vendorPaymentDate: item.draftDate,
title: item.title,
description: item.title,
reason: '업무상 필요',
estimatedCost: 0,
attachments: [],
approvers,
drafter,
};
}
};
// ===== 탭 옵션 =====
// ===== 탭 옵션 =====
const tabs: TabOption[] = useMemo(
() => [
{
@@ -739,12 +781,17 @@ export function ApprovalBox() {
</AlertDialog>
{/* 문서 상세 모달 */}
{selectedDocument && (
{selectedDocument && modalData && (
<DocumentDetailModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setModalData(null);
}
}}
documentType={getDocumentType(selectedDocument.approvalType)}
data={convertToModalData(selectedDocument)}
data={modalData}
mode="inbox"
onEdit={handleModalEdit}
onCopy={handleModalCopy}
@@ -778,6 +825,7 @@ export function ApprovalBox() {
handleRejectConfirm,
selectedDocument,
isModalOpen,
modalData,
handleModalEdit,
handleModalCopy,
handleModalApprove,

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { Mic } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -8,14 +9,61 @@ import { CurrencyInput } from '@/components/ui/currency-input';
import { Textarea } from '@/components/ui/textarea';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getClients } from '@/components/accounting/VendorManagement/actions';
import type { ProposalData, UploadedFile } from './types';
// 거래처 옵션 타입
interface ClientOption {
id: string;
name: string;
}
interface ProposalFormProps {
data: ProposalData;
onChange: (data: ProposalData) => void;
}
export function ProposalForm({ data, onChange }: ProposalFormProps) {
// 거래처 목록 상태
const [clients, setClients] = useState<ClientOption[]>([]);
const [isLoadingClients, setIsLoadingClients] = useState(true);
// 거래처 목록 로드 (매입 거래처만)
useEffect(() => {
async function loadClients() {
setIsLoadingClients(true);
const result = await getClients({ size: 1000, only_active: true });
if (result.success) {
// 매입 거래처(purchase, both)만 필터링
const purchaseClients = result.data
.filter((v) => v.category === 'purchase' || v.category === 'both')
.map((v) => ({
id: v.id,
name: v.vendorName,
}));
setClients(purchaseClients);
}
setIsLoadingClients(false);
}
loadClients();
}, []);
// 거래처 선택 핸들러
const handleVendorChange = (vendorId: string) => {
const selected = clients.find((c) => c.id === vendorId);
onChange({
...data,
vendorId,
vendor: selected?.name || '',
});
};
const handleFilesSelect = (files: File[]) => {
onChange({ ...data, attachments: [...data.attachments, ...files] });
};
@@ -41,12 +89,22 @@ export function ProposalForm({ data, onChange }: ProposalFormProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vendor"></Label>
<Input
id="vendor"
placeholder="구매처를 입력해주세요"
value={data.vendor}
onChange={(e) => onChange({ ...data, vendor: e.target.value })}
/>
<Select
value={data.vendorId || ''}
onValueChange={handleVendorChange}
disabled={isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? '불러오는 중...' : '구매처를 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">

View File

@@ -760,7 +760,8 @@ function transformApiToFormData(apiData: {
department,
};
if (step.step_type === 'approval') {
// 'approval'과 'agreement' 모두 결재선에 포함
if (step.step_type === 'approval' || step.step_type === 'agreement') {
approvalLine.push(person);
} else if (step.step_type === 'reference') {
references.push(person);
@@ -808,6 +809,7 @@ function transformApiToFormData(apiData: {
if (documentType === 'proposal') {
proposalData = {
vendorId: (content.vendorId as string) || '',
vendor: (content.vendor as string) || '',
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
title: (content.title as string) || '',
@@ -894,6 +896,7 @@ function getDocumentContent(
switch (formData.basicInfo.documentType) {
case 'proposal':
return {
vendorId: formData.proposalData?.vendorId,
vendor: formData.proposalData?.vendor,
vendorPaymentDate: formData.proposalData?.vendorPaymentDate,
title: formData.proposalData?.title,

View File

@@ -45,6 +45,7 @@ import type {
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
import { getClients } from '@/components/accounting/VendorManagement/actions';
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
const getInitialBasicInfo = (): BasicInfo => ({
@@ -55,6 +56,7 @@ const getInitialBasicInfo = (): BasicInfo => ({
});
const getInitialProposalData = (): ProposalData => ({
vendorId: '',
vendor: '',
vendorPaymentDate: '', // 클라이언트에서 설정
title: '',
@@ -131,6 +133,19 @@ export function DocumentCreate() {
// 직원 목록 가져오기
const employees = await getEmployees();
// 거래처 목록 가져오기 (매입 거래처만)
const clientsResult = await getClients({ size: 1000, only_active: true });
const purchaseClients = clientsResult.success
? clientsResult.data
.filter((v) => v.category === 'purchase' || v.category === 'both')
.map((v) => ({ id: v.id, name: v.vendorName }))
: [];
// 랜덤 거래처 선택
const randomClient = purchaseClients.length > 0
? purchaseClients[Math.floor(Math.random() * purchaseClients.length)]
: null;
// localStorage에서 실제 로그인 사용자 이름 가져오기 (우측 상단 표시와 동일한 소스)
const userDataStr = localStorage.getItem("user");
const currentUserName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name;
@@ -171,6 +186,9 @@ export function DocumentCreate() {
setProposalData(prev => ({
...prev,
...mockData.proposalData,
// 실제 API 거래처로 덮어쓰기
vendorId: randomClient?.id || '',
vendor: randomClient?.name || '',
}));
toast.success('지출결의서 데이터가 자동 입력되었습니다.');
}

View File

@@ -38,7 +38,8 @@ export interface BasicInfo {
// 품의서 데이터
export interface ProposalData {
vendor: string;
vendorId: string; // 거래처 ID (API 연동)
vendor: string; // 거래처명 (표시용)
vendorPaymentDate: string;
title: string;
description: string;