summaryrefslogtreecommitdiff
path: root/components/knox
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-23 11:50:00 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-23 11:50:00 +0900
commitc64264ac25716256c0c7c4f56e6f459747f4ef11 (patch)
tree5c891882bb9fc1050b11bea64b9e01cbacedfdb1 /components/knox
parentf88c061511694e97892f9c6266151ce323790a99 (diff)
(김준회) 결재 이력조회 처리 및 취소는 상세로 이동처리
Diffstat (limited to 'components/knox')
-rw-r--r--components/knox/approval/ApprovalDetail.tsx156
-rw-r--r--components/knox/approval/ApprovalList.tsx263
-rw-r--r--components/knox/approval/ApprovalManager.tsx47
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