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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 문서 영역 - 스크롤 */}
|
||||
|
||||
@@ -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; // 상신 콜백
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user