diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/knox/approval/ApprovalDetail.tsx | 156 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalList.tsx | 263 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalManager.tsx | 47 |
3 files changed, 238 insertions, 228 deletions
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx index 1be58d21..e3197522 100644 --- a/components/knox/approval/ApprovalDetail.tsx +++ b/components/knox/approval/ApprovalDetail.tsx @@ -7,11 +7,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; +import { Textarea } from '@/components/ui/textarea'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; -import { Loader2, Search, FileText, Clock, User, AlertCircle } from 'lucide-react'; +import { Loader2, Search, FileText, Clock, User, AlertCircle, XCircle } from 'lucide-react'; // API 함수 및 타입 -import { getApprovalDetail, getApprovalContent } from '@/lib/knox-api/approval/approval'; +import { getApprovalDetail, getApprovalContent, cancelApproval } from '@/lib/knox-api/approval/approval'; import type { ApprovalDetailResponse, ApprovalContentResponse, ApprovalLine } from '@/lib/knox-api/approval/approval'; import { formatDate } from '@/lib/utils'; @@ -70,6 +72,8 @@ export default function ApprovalDetail({ const [approvalData, setApprovalData] = useState<ApprovalDetailData | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const [isCancelling, setIsCancelling] = useState(false); + const [cancelOpinion, setCancelOpinion] = useState(''); const fetchApprovalDetail = async (id: string) => { if (!id.trim()) { @@ -142,6 +146,49 @@ export default function ApprovalDetail({ } }; + const canCancelApproval = (status: string) => { + // 진행중(1), 보류(0) 상태에서만 취소 가능 + return ['0', '1'].includes(status); + }; + + const handleCancelApproval = async () => { + if (!approvalData) return; + + if (!cancelOpinion.trim()) { + toast.error('상신취소 의견을 입력해주세요.'); + return; + } + + setIsCancelling(true); + + try { + const response = await cancelApproval(approvalData.detail.apInfId, cancelOpinion); + + if (response.result === 'success') { + toast.success('결재가 성공적으로 취소되었습니다.'); + + // 상태 업데이트 + setApprovalData({ + ...approvalData, + detail: { + ...approvalData.detail, + status: '4' // 상신취소 + } + }); + + // 의견 초기화 + setCancelOpinion(''); + } else { + toast.error('결재 취소에 실패했습니다.'); + } + } catch (err) { + console.error('결재 취소 오류:', err); + toast.error('결재 취소 중 오류가 발생했습니다.'); + } finally { + setIsCancelling(false); + } + }; + // 첨부파일 다운로드 헬퍼 const handleDownload = async (attachment: ApprovalAttachment) => { try { @@ -301,6 +348,111 @@ export default function ApprovalDetail({ </div> </div> + {/* 결재 취소 섹션 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <XCircle className="w-5 h-5" /> + 결재 취소 + </h3> + + <div className={`p-4 rounded-lg border ${ + canCancelApproval(approvalData.detail.status) + ? 'bg-blue-50 border-blue-200' + : 'bg-yellow-50 border-yellow-200' + }`}> + <div className="flex items-center gap-2 mb-2"> + <span className={`font-medium ${ + canCancelApproval(approvalData.detail.status) + ? 'text-blue-700' + : 'text-yellow-700' + }`}> + {canCancelApproval(approvalData.detail.status) ? '취소 가능' : '취소 불가'} + </span> + </div> + <p className={`text-sm ${ + canCancelApproval(approvalData.detail.status) + ? 'text-blue-600' + : 'text-yellow-600' + }`}> + {canCancelApproval(approvalData.detail.status) + ? '이 결재는 취소할 수 있습니다.' + : '현재 상태에서는 취소할 수 없습니다.'} + </p> + </div> + + {/* 취소 의견 및 버튼 */} + {canCancelApproval(approvalData.detail.status) && ( + <div className="space-y-4"> + <div> + <Label htmlFor="cancelOpinion" className="text-sm font-medium"> + 상신취소 의견 <span className="text-red-500">*</span> + </Label> + <Textarea + id="cancelOpinion" + placeholder="상신취소 사유를 입력해주세요" + value={cancelOpinion} + onChange={(e) => setCancelOpinion(e.target.value)} + className="mt-1" + rows={3} + /> + <p className="text-xs text-gray-500 mt-1"> + 상신취소 의견은 필수 입력 항목입니다. + </p> + </div> + + <div className="flex justify-end"> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="destructive" + disabled={isCancelling || !cancelOpinion.trim()} + > + {isCancelling ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 취소 중... + </> + ) : ( + <> + <XCircle className="w-4 h-4 mr-2" /> + 결재 취소 + </> + )} + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>결재 취소 확인</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 결재를 취소하시겠습니까? + <br /> + <br /> + <strong>결재 ID:</strong> {approvalData.detail.apInfId} + <br /> + <strong>제목:</strong> {approvalData.detail.subject} + <br /> + <strong>취소 의견:</strong> {cancelOpinion} + <br /> + <br /> + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleCancelApproval} + className="bg-red-600 hover:bg-red-700" + > + 결재 취소 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </div> + )} + </div> + <Separator /> {/* 결재 내용 */} diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx index ec47bf04..c63098a8 100644 --- a/components/knox/approval/ApprovalList.tsx +++ b/components/knox/approval/ApprovalList.tsx @@ -6,11 +6,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { toast } from 'sonner'; -import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react'; +import { Loader2, List, Eye, RefreshCw, AlertCircle, Play, Pause, Clock } from 'lucide-react'; // API 함수 및 타입 -import { getSubmissionList, getApprovalHistory, getApprovalLogsAction, syncApprovalStatusAction } from '@/lib/knox-api/approval/approval'; -import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval'; +import { getApprovalLogsAction, syncApprovalStatusAction } from '@/lib/knox-api/approval/approval'; import { formatDate } from '@/lib/utils'; // 상태 텍스트 매핑 (mock util 대체) @@ -31,13 +30,7 @@ const getStatusText = (status: string) => { }; interface ApprovalListProps { - type?: 'submission' | 'history' | 'database'; onItemClick?: (apInfId: string) => void; - userParams?: { - epId?: string; - userId?: string; - emailAddress?: string; - }; } type ListItem = { @@ -53,51 +46,28 @@ type ListItem = { }; export default function ApprovalList({ - type = 'database', onItemClick, - userParams }: ApprovalListProps) { const [listData, setListData] = useState<ListItem[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [isSyncing, setIsSyncing] = useState(false); + const [autoSync, setAutoSync] = useState(false); + const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null); const fetchData = useCallback(async () => { setIsLoading(true); setError(null); try { - if (type === 'database') { - // 새로운 데이터베이스 조회 방식 - const response = await getApprovalLogsAction(); - - if (response.success) { - setListData(response.data as unknown as ListItem[]); - } else { - setError(response.message); - toast.error(response.message); - } + // 데이터베이스에서 결재 로그 조회 + const response = await getApprovalLogsAction(); + + if (response.success) { + setListData(response.data as unknown as ListItem[]); } else { - // 기존 Knox API 방식 - let response: SubmissionListResponse | ApprovalHistoryResponse; - - if (type === 'submission') { - if (!userParams || (!userParams.epId && !userParams.userId && !userParams.emailAddress)) { - setError('사용자 정보가 필요합니다. (epId, userId, 또는 emailAddress)'); - toast.error('사용자 정보가 필요합니다.'); - return; - } - response = await getSubmissionList(userParams); - } else { - response = await getApprovalHistory(); - } - - if (response.result === 'success') { - setListData(response.data as unknown as ListItem[]); - } else { - setError('목록을 가져오는데 실패했습니다.'); - toast.error('목록을 가져오는데 실패했습니다.'); - } + setError(response.message); + toast.error(response.message); } } catch (err) { console.error('목록 조회 오류:', err); @@ -106,7 +76,7 @@ export default function ApprovalList({ } finally { setIsLoading(false); } - }, [type, userParams]); + }, []); const getStatusBadgeVariant = (status: string) => { switch (status) { @@ -123,123 +93,114 @@ export default function ApprovalList({ } }; - const getSecurityTypeText = (type: string) => { - const typeMap: Record<string, string> = { - 'PERSONAL': '개인', - 'CONFIDENTIAL': '기밀', - 'CONFIDENTIAL_STRICT': '극기밀' - }; - return typeMap[type] || type; - }; - - const getSecurityTypeBadgeVariant = (type: string) => { - switch (type) { - case 'PERSONAL': - return 'default'; - case 'CONFIDENTIAL': - return 'secondary'; - case 'CONFIDENTIAL_STRICT': - return 'destructive'; - default: - return 'outline'; - } - }; - - const getActionTypeText = (actionType: string) => { - const actionMap: Record<string, string> = { - 'SUBMIT': '상신', - 'APPROVE': '승인', - 'REJECT': '반려', - 'CANCEL': '취소', - 'DELEGATE': '위임' - }; - return actionMap[actionType] || actionType; - }; - const handleItemClick = (apInfId: string) => { onItemClick?.(apInfId); }; // 결재 상황 동기화 함수 - const handleSync = async () => { - if (type !== 'database') { - toast.error('데이터베이스 모드에서만 동기화가 가능합니다.'); - return; - } - + const handleSync = useCallback(async (silent = false) => { setIsSyncing(true); try { const result = await syncApprovalStatusAction(); if (result.success) { - toast.success(result.message); + if (!silent) toast.success(result.message); + setLastSyncTime(new Date()); // 동기화 후 데이터 새로고침 await fetchData(); } else { - toast.error(result.message); + if (!silent) toast.error(result.message); } } catch (error) { console.error('동기화 오류:', error); - toast.error('동기화 중 오류가 발생했습니다.'); + if (!silent) toast.error('동기화 중 오류가 발생했습니다.'); } finally { setIsSyncing(false); } - }; + }, [fetchData]); // 컴포넌트 마운트 시 데이터 로드 useEffect(() => { fetchData(); }, [fetchData]); + // 자동 동기화 Polling 효과 + useEffect(() => { + if (!autoSync) { + return; + } + + // 30초마다 자동 동기화 (조정 가능) + const intervalId = setInterval(() => { + handleSync(true); // silent 모드로 실행 + }, 30000); + + return () => clearInterval(intervalId); + }, [autoSync, handleSync]); + return ( <Card className="w-full max-w-5xl"> <CardHeader> <CardTitle className="flex items-center gap-2"> <List className="w-5 h-5" /> - {type === 'database' - ? '결재 로그 (데이터베이스)' - : type === 'submission' - ? '상신함' - : '결재 이력' - } + 결재 이력 </CardTitle> <CardDescription> - {type === 'database' - ? '데이터베이스에 저장된 결재 로그를 확인합니다.' - : type === 'submission' - ? '상신한 결재 목록을 확인합니다.' - : '결재 처리 이력을 확인합니다.' - } + 데이터베이스에 저장된 결재 로그를 확인하고 상태를 동기화합니다. </CardDescription> </CardHeader> <CardContent className="space-y-4"> {/* 제어 버튼들 */} <div className="flex justify-between items-center"> - <div className="text-sm text-gray-500"> - 총 {listData.length}건 + <div className="flex items-center gap-4"> + <div className="text-sm text-gray-500"> + 총 {listData.length}건 + </div> + {lastSyncTime && ( + <div className="text-xs text-gray-400 flex items-center gap-1"> + <Clock className="w-3 h-3" /> + 마지막 동기화: {formatDate(lastSyncTime.toISOString(), "kr")} + </div> + )} </div> <div className="flex gap-2"> - {type === 'database' && ( - <Button - onClick={handleSync} - disabled={isSyncing || isLoading} - variant="secondary" - size="sm" - > - {isSyncing ? ( - <> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 동기화 중... - </> - ) : ( - <> - <RefreshCw className="w-4 h-4 mr-2" /> - 상태 동기화 - </> - )} - </Button> - )} + <Button + onClick={() => setAutoSync(!autoSync)} + disabled={isSyncing || isLoading} + variant={autoSync ? "default" : "outline"} + size="sm" + > + {autoSync ? ( + <> + <Pause className="w-4 h-4 mr-2" /> + 자동 동기화 중 + </> + ) : ( + <> + <Play className="w-4 h-4 mr-2" /> + 자동 동기화 + </> + )} + </Button> + <Button + onClick={() => handleSync(false)} + disabled={isSyncing || isLoading} + variant="secondary" + size="sm" + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <RefreshCw className="w-4 h-4 mr-2" /> + 상태 동기화 + </> + )} + </Button> <Button onClick={fetchData} disabled={isLoading || isSyncing} @@ -279,29 +240,15 @@ export default function ApprovalList({ <TableRow> <TableHead>결재 ID</TableHead> <TableHead>제목</TableHead> - <TableHead>상신일시</TableHead> <TableHead>상태</TableHead> - {type === 'submission' && ( - <> - <TableHead>긴급</TableHead> - <TableHead>보안등급</TableHead> - </> - )} - {type === 'history' && ( - <> - <TableHead>처리일시</TableHead> - <TableHead>처리자</TableHead> - <TableHead>처리유형</TableHead> - </> - )} <TableHead>작업</TableHead> </TableRow> </TableHeader> <TableBody> {listData.length === 0 ? ( <TableRow> - <TableCell - colSpan={type === 'submission' ? 7 : 8} + <TableCell + colSpan={4} className="text-center py-8 text-gray-500" > {isLoading ? '데이터를 불러오는 중...' : '데이터가 없습니다.'} @@ -317,56 +264,10 @@ export default function ApprovalList({ {item.subject} </TableCell> <TableCell> - {formatDate(item.sbmDt, "kr")} - </TableCell> - <TableCell> <Badge variant={getStatusBadgeVariant(item.status)}> {getStatusText(item.status)} </Badge> </TableCell> - - {type === 'submission' && ( - <> - <TableCell> - {item.urgYn === 'Y' ? ( - <Badge variant="destructive" className="text-xs"> - 긴급 - </Badge> - ) : ( - <Badge variant="outline" className="text-xs"> - 일반 - </Badge> - )} - </TableCell> - <TableCell> - <Badge - variant={getSecurityTypeBadgeVariant(item.docSecuType || 'PERSONAL')} - className="text-xs" - > - {getSecurityTypeText(item.docSecuType || 'PERSONAL')} - </Badge> - </TableCell> - </> - )} - - {type === 'history' && ( - <> - <TableCell> - {item.actionDt ? formatDate(item.actionDt, "kr") : '-'} - </TableCell> - <TableCell> - {item.userId || '-'} - </TableCell> - <TableCell> - {item.actionType ? ( - <Badge variant="outline" className="text-xs"> - {getActionTypeText(item.actionType)} - </Badge> - ) : '-'} - </TableCell> - </> - )} - <TableCell> <Button variant="ghost" diff --git a/components/knox/approval/ApprovalManager.tsx b/components/knox/approval/ApprovalManager.tsx index 0d5c300a..5fd54a0c 100644 --- a/components/knox/approval/ApprovalManager.tsx +++ b/components/knox/approval/ApprovalManager.tsx @@ -4,13 +4,11 @@ import { useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { FileText, Eye, XCircle, List, History, Settings } from 'lucide-react'; +import { FileText, Eye, History } from 'lucide-react'; // 결재 컴포넌트들 import ApprovalSubmit from './ApprovalSubmit'; import ApprovalDetail from './ApprovalDetail'; -import ApprovalCancel from './ApprovalCancel'; import ApprovalList from './ApprovalList'; interface ApprovalManagerProps { @@ -35,10 +33,6 @@ export default function ApprovalManager({ setCurrentTab('detail'); }; - const handleCancelSuccess = (apInfId: string) => { - setSelectedApInfId(apInfId); - setCurrentTab('detail'); - }; const handleListItemClick = (apInfId: string) => { setSelectedApInfId(apInfId); @@ -76,7 +70,7 @@ export default function ApprovalManager({ {/* 메인 탭 */} <Tabs value={currentTab} onValueChange={setCurrentTab} className="w-full"> - <TabsList className="grid w-full grid-cols-4"> + <TabsList className="grid w-full grid-cols-3"> <TabsTrigger value="submit" className="flex items-center gap-2"> <FileText className="w-4 h-4" /> 상신 @@ -85,10 +79,6 @@ export default function ApprovalManager({ <Eye className="w-4 h-4" /> 상세조회 </TabsTrigger> - <TabsTrigger value="cancel" className="flex items-center gap-2"> - <XCircle className="w-4 h-4" /> - 취소 - </TabsTrigger> {/* <TabsTrigger value="list" className="flex items-center gap-2"> <List className="w-4 h-4" /> 상신함 @@ -113,15 +103,6 @@ export default function ApprovalManager({ </div> </TabsContent> - {/* 결재 취소 탭 */} - <TabsContent value="cancel" className="space-y-6"> - <div className="w-full"> - <ApprovalCancel - initialApInfId={selectedApInfId} - onCancelSuccess={handleCancelSuccess} - /> - </div> - </TabsContent> {/* 상신함 탭 */} {/* <TabsContent value="list" className="space-y-6"> @@ -137,35 +118,11 @@ export default function ApprovalManager({ <TabsContent value="history" className="space-y-6"> <div className="w-full"> <ApprovalList - type="history" onItemClick={handleListItemClick} /> </div> </TabsContent> </Tabs> - - {/* 하단 정보 */} - <Card> - <CardContent className="pt-6"> - <div className="flex items-center justify-between text-sm text-gray-500"> - <div className="flex items-center gap-4"> - <div className="flex items-center gap-2"> - <Settings className="w-4 h-4" /> - <span>Knox API 결재 시스템</span> - </div> - <Separator orientation="vertical" className="h-4" /> - <div> - Next.js 15 + shadcn/ui + TypeScript - </div> - </div> - - <div className="flex items-center gap-2"> - <span>버전:</span> - <Badge variant="outline">v1.0.0</Badge> - </div> - </div> - </CardContent> - </Card> </div> ); }
\ No newline at end of file |
