feat: 기안함/결재함 상세 모달 버튼 분기 및 수정 기능 추가

- 기안함 임시저장 상세: 복제, 상신, 인쇄 버튼 표시
- 기안함 결재대기 이후 상세: 인쇄만 표시
- 결재함 상세: 수정, 반려, 승인, 인쇄 버튼 표시
- 결재함 리스트 작업컬럼 수정 버튼 → 기안함 수정 페이지 이동
- DocumentDetailModal mode/documentStatus 기반 조건부 렌더링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-29 21:14:08 +09:00
parent 388b113b58
commit 0e5307f7a3
5 changed files with 163 additions and 47 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import {
FileCheck,
Check,
@@ -78,6 +79,7 @@ interface InboxSummary {
}
export function ApprovalBox() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 상태 관리 =====
@@ -108,8 +110,9 @@ export function ApprovalBox() {
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
// 통계 데이터
// 통계 데이터 (전체 탭 기준으로 고정 유지)
const [summary, setSummary] = useState<InboxSummary | null>(null);
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
@@ -195,15 +198,24 @@ export function ApprovalBox() {
}
}, [selectedItems.size, data]);
// ===== 통계 데이터 (API summary 사용) =====
const stats = useMemo(() => {
return {
all: summary?.total ?? 0,
pending: summary?.pending ?? 0,
approved: summary?.approved ?? 0,
rejected: summary?.rejected ?? 0,
};
}, [summary]);
// ===== 전체 탭일 때만 통계 데이 =====
useEffect(() => {
if (activeTab === 'all' && data.length > 0) {
const pending = data.filter(item => item.status === 'pending').length;
const approved = data.filter(item => item.status === 'approved').length;
const rejected = data.filter(item => item.status === 'rejected').length;
setFixedStats({
all: totalCount,
pending,
approved,
rejected,
});
}
}, [data, totalCount, activeTab]);
// ===== 통계 데이터 (고정 값 사용) =====
const stats = fixedStats;
// ===== 승인/반려 핸들러 =====
const handleApproveClick = useCallback(() => {
@@ -310,10 +322,16 @@ export function ApprovalBox() {
}, []);
const handleModalEdit = useCallback(() => {
// TODO: 수정 페이지로 이동 - 라우터 연동 필요
console.log('[ApprovalBox] 문서 수정 - 라우터 연동 필요:', selectedDocument?.id);
setIsModalOpen(false);
}, [selectedDocument]);
if (selectedDocument) {
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
setIsModalOpen(false);
}
}, [selectedDocument, router]);
// 리스트에서 수정 버튼 클릭 시 핸들러
const handleEditClick = useCallback((item: ApprovalRecord) => {
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
}, [router]);
const handleModalCopy = useCallback(() => {
// TODO: 문서 복제 API 개발 필요 - POST /api/v1/approvals/{id}/copy
@@ -460,7 +478,8 @@ export function ApprovalBox() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDocumentClick(item)}
onClick={() => handleEditClick(item)}
title="기안함 수정 페이지로 이동"
>
<Edit className="h-4 w-4" />
</Button>
@@ -468,7 +487,7 @@ export function ApprovalBox() {
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleDocumentClick]);
}, [selectedItems, toggleSelection, handleDocumentClick, handleEditClick]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
@@ -669,6 +688,7 @@ export function ApprovalBox() {
onOpenChange={setIsModalOpen}
documentType={getDocumentType(selectedDocument.approvalType)}
data={convertToModalData(selectedDocument)}
mode="inbox"
onEdit={handleModalEdit}
onCopy={handleModalCopy}
onApprove={handleModalApprove}

View File

@@ -408,6 +408,12 @@ export function DocumentCreate() {
drafter,
};
default:
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
`/api/files/${f.id}/download`
);
const newFileUrls = proposalData.attachments.map(f => URL.createObjectURL(f));
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
@@ -417,7 +423,7 @@ export function DocumentCreate() {
description: proposalData.description || '-',
reason: proposalData.reason || '-',
estimatedCost: proposalData.estimatedCost,
attachments: proposalData.attachments.map(f => f.name),
attachments: [...uploadedFileUrls, ...newFileUrls],
approvers,
drafter,
};
@@ -550,6 +556,19 @@ export function DocumentCreate() {
onOpenChange={setIsPreviewOpen}
documentType={basicInfo.documentType as ModalDocumentType}
data={getPreviewData()}
mode="draft"
documentStatus="draft"
onCopy={() => {
// 복제: 현재 데이터를 기반으로 새 문서 등록 화면으로 이동
if (documentId) {
router.push(`/ko/approval/draft/new?copyFrom=${documentId}`);
setIsPreviewOpen(false);
}
}}
onSubmit={() => {
setIsPreviewOpen(false);
handleSubmit();
}}
/>
</div>
);

View File

@@ -25,6 +25,7 @@ import {
Mail,
Phone,
MessageCircle,
Send,
} from 'lucide-react';
import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
@@ -42,11 +43,17 @@ export function DocumentDetailModal({
onOpenChange,
documentType,
data,
mode = 'inbox', // 기본값: 결재함 (승인/반려 표시)
documentStatus,
onEdit,
onCopy,
onApprove,
onReject,
onSubmit,
}: DocumentDetailModalProps) {
// 기안함 모드에서 임시저장 상태일 때만 수정/상신 가능
const canEdit = mode === 'inbox' || documentStatus === 'draft';
const canSubmit = mode === 'draft' && documentStatus === 'draft';
const getDocumentTitle = () => {
switch (documentType) {
case 'proposal':
@@ -115,29 +122,47 @@ export function DocumentDetailModal({
{/* 버튼 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={onCopy}>
<Copy className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={onReject} className="text-red-600 hover:text-red-700">
<XIcon className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={onApprove} className="bg-blue-600 hover:bg-blue-700">
<CheckCircle className="h-4 w-4 mr-1" />
</Button>
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
{mode === 'draft' && documentStatus === 'draft' && (
<>
<Button variant="outline" size="sm" onClick={onCopy}>
<Copy className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={onSubmit} className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
</>
)}
{/* 기안함 모드 + 결재대기 이후 상태: 인쇄만 (버튼 없음, 아래 인쇄 버튼만 표시) */}
{/* 결재함 모드: 수정, 반려, 승인, 인쇄 */}
{mode === 'inbox' && (
<>
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={onReject} className="text-red-600 hover:text-red-700">
<XIcon className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={onApprove} className="bg-blue-600 hover:bg-blue-700">
<CheckCircle className="h-4 w-4 mr-1" />
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
{/* 공유 드롭다운 */}
<DropdownMenu>
{/* TODO: 공유 기능 추가 예정 - PDF 다운로드, 이메일, 팩스, 카카오톡 공유 */}
{/* <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-1" />
@@ -163,7 +188,7 @@ export function DocumentDetailModal({
카카오톡
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu> */}
</div>
{/* 문서 영역 - 스크롤 */}

View File

@@ -72,14 +72,23 @@ export interface ExpenseEstimateDocumentData {
drafter: Approver;
}
// 문서 상세 모달 모드
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
// 문서 상태 (기안함 기준)
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected';
// 문서 상세 모달 Props
export interface DocumentDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentType;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
onEdit?: () => void;
onCopy?: () => void;
onApprove?: () => void;
onReject?: () => void;
onSubmit?: () => void; // 상신 콜백
}

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import {
getDrafts,
getDraftsSummary,
getDraftById,
deleteDraft,
deleteDrafts,
submitDraft,
@@ -238,14 +239,21 @@ export function DraftBox() {
// ===== 문서 클릭/수정 핸들러 (조건부 로직) =====
// 임시저장 → 문서 작성 페이지 (수정 모드)
// 그 외 → 문서 상세 모달
const handleDocumentClick = useCallback((item: DraftRecord) => {
// 그 외 → 문서 상세 모달 (상세 API 호출하여 content 포함된 데이터 가져옴)
const handleDocumentClick = useCallback(async (item: DraftRecord) => {
if (item.status === 'draft') {
// 임시저장 상태 → 문서 작성 페이지로 이동 (수정 모드)
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
} else {
// 그 외 상태 → 문서 상세 모달 열기
setSelectedDocument(item);
// 그 외 상태 → 문서 상세 API 호출 후 모달 열기
// 목록 API에서는 content가 포함되지 않을 수 있으므로 상세 조회 필요
const detailData = await getDraftById(item.id);
if (detailData) {
setSelectedDocument(detailData);
} else {
// 상세 조회 실패 시 기존 데이터 사용
setSelectedDocument(item);
}
setIsModalOpen(true);
}
}, [router]);
@@ -258,9 +266,14 @@ export function DraftBox() {
}, [selectedDocument, router]);
const handleModalCopy = useCallback(() => {
console.log('[DraftBox] handleModalCopy 호출됨, selectedDocument:', selectedDocument);
if (selectedDocument) {
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
const copyUrl = `/ko/approval/draft/new?copyFrom=${selectedDocument.id}`;
console.log('[DraftBox] 복제 URL로 이동:', copyUrl);
router.push(copyUrl);
setIsModalOpen(false);
} else {
console.log('[DraftBox] selectedDocument가 없음');
}
}, [selectedDocument, router]);
@@ -276,6 +289,29 @@ export function DraftBox() {
setIsModalOpen(false);
}, []);
// ===== 모달에서 상신 핸들러 =====
const handleModalSubmit = useCallback(async () => {
if (!selectedDocument) return;
startTransition(async () => {
try {
const result = await submitDraft(selectedDocument.id);
if (result.success) {
toast.success('문서를 상신했습니다.');
setIsModalOpen(false);
setSelectedDocument(null);
loadData();
loadSummary();
} else {
toast.error(result.error || '상신에 실패했습니다.');
}
} catch (error) {
console.error('Submit error:', error);
toast.error('상신 중 오류가 발생했습니다.');
}
});
}, [selectedDocument, loadData, loadSummary]);
// ===== DraftRecord → 모달용 데이터 변환 =====
const getDocumentType = (item: DraftRecord): DocumentType => {
// documentTypeCode 우선 사용, 없으면 documentType(양식명)으로 추론
@@ -371,7 +407,11 @@ export function DraftBox() {
}
default: {
// proposal (품의서)
// API content 구조: { vendor, vendorPaymentDate, title, description, reason, estimatedCost }
// API content 구조: { vendor, vendorPaymentDate, title, description, reason, estimatedCost, files }
const files = (content.files as Array<{ id: number; name: string; url?: string }>) || [];
// Next.js 프록시 URL 사용 (인증된 요청 프록시)
const attachmentUrls = files.map(f => `/api/files/${f.id}/download`);
return {
documentNo: item.documentNo,
createdAt: item.draftDate,
@@ -381,7 +421,7 @@ export function DraftBox() {
description: (content.description as string) || '',
reason: (content.reason as string) || '',
estimatedCost: (content.estimatedCost as number) || 0,
attachments: [],
attachments: attachmentUrls,
approvers,
drafter,
};
@@ -451,7 +491,8 @@ export function DraftBox() {
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
{/* 임시저장 상태일 때만 수정/삭제 버튼 표시 */}
{isSelected && item.status === 'draft' && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
@@ -510,7 +551,8 @@ export function DraftBox() {
</div>
}
actions={
isSelected ? (
/* 임시저장 상태일 때만 수정/삭제 버튼 표시 */
isSelected && item.status === 'draft' ? (
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => handleDocumentClick(item)}>
<Pencil className="w-4 h-4 mr-2" />
@@ -628,10 +670,11 @@ export function DraftBox() {
onOpenChange={setIsModalOpen}
documentType={getDocumentType(selectedDocument)}
data={convertToModalData(selectedDocument)}
mode="draft"
documentStatus={selectedDocument.status}
onEdit={handleModalEdit}
onCopy={handleModalCopy}
onApprove={handleModalApprove}
onReject={handleModalReject}
onSubmit={handleModalSubmit}
/>
)}
</>