feat(WEB): 결재함 문서 상세 모달 데이터 연동 개선
- ApprovalBox: 문서 클릭 시 API 데이터 로드하여 모달에 표시 - DocumentCreate: 품의서 폼 개선 및 actions 수정 - 결재자 정보 (직책, 부서) 표시 개선
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('지출결의서 데이터가 자동 입력되었습니다.');
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ export interface BasicInfo {
|
||||
|
||||
// 품의서 데이터
|
||||
export interface ProposalData {
|
||||
vendor: string;
|
||||
vendorId: string; // 거래처 ID (API 연동)
|
||||
vendor: string; // 거래처명 (표시용)
|
||||
vendorPaymentDate: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
Reference in New Issue
Block a user