1004 lines
41 KiB
TypeScript
1004 lines
41 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Loader2, Send, Inbox, CheckCircle, XCircle, Eye,
|
|
UserCog, Plus, Pencil, Trash2, Search,
|
|
} from "lucide-react";
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
import {
|
|
getApprovalRequests,
|
|
getApprovalRequest,
|
|
getMyPendingApprovals,
|
|
processApprovalLine,
|
|
cancelApprovalRequest,
|
|
getProxySettings,
|
|
createProxySetting,
|
|
updateProxySetting,
|
|
deleteProxySetting,
|
|
type ApprovalRequest,
|
|
type ApprovalLine,
|
|
type ApprovalProxySetting,
|
|
} from "@/lib/api/approval";
|
|
import { getUserList } from "@/lib/api/user";
|
|
|
|
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
|
requested: { label: "요청", variant: "outline" },
|
|
in_progress: { label: "진행중", variant: "default" },
|
|
approved: { label: "승인", variant: "default" },
|
|
rejected: { label: "반려", variant: "destructive" },
|
|
cancelled: { label: "회수", variant: "secondary" },
|
|
waiting: { label: "대기", variant: "outline" },
|
|
pending: { label: "결재대기", variant: "default" },
|
|
skipped: { label: "건너뜀", variant: "secondary" },
|
|
};
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
|
|
return <Badge variant={info.variant}>{info.label}</Badge>;
|
|
}
|
|
|
|
function formatDate(dateStr?: string) {
|
|
if (!dateStr) return "-";
|
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// 상신함 (내가 올린 결재)
|
|
// ============================================================
|
|
function SentTab() {
|
|
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const fetchRequests = useCallback(async () => {
|
|
setLoading(true);
|
|
const res = await getApprovalRequests({ my_approvals: false });
|
|
if (res.success && res.data) setRequests(res.data);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchRequests(); }, [fetchRequests]);
|
|
|
|
const openDetail = async (req: ApprovalRequest) => {
|
|
setDetailLoading(true);
|
|
setDetailOpen(true);
|
|
const res = await getApprovalRequest(req.request_id);
|
|
if (res.success && res.data) {
|
|
setSelectedRequest(res.data);
|
|
} else {
|
|
setSelectedRequest(req);
|
|
}
|
|
setDetailLoading(false);
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
if (!selectedRequest) return;
|
|
const res = await cancelApprovalRequest(selectedRequest.request_id);
|
|
if (res.success) {
|
|
toast.success("결재가 회수되었습니다.");
|
|
setDetailOpen(false);
|
|
fetchRequests();
|
|
} else {
|
|
toast.error(res.error || "회수 실패");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : requests.length === 0 ? (
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
<Send className="text-muted-foreground mb-2 h-8 w-8" />
|
|
<p className="text-muted-foreground text-sm">상신한 결재가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-card rounded-lg border shadow-sm">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">진행</TableHead>
|
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
|
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold">보기</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{requests.map((req) => (
|
|
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
|
|
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
|
|
<TableCell className="h-14 text-center text-sm">
|
|
{req.current_step}/{req.total_steps}
|
|
</TableCell>
|
|
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
|
|
<TableCell className="h-14 text-center">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 상세 모달 */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">결재 상세</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{selectedRequest?.title}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{detailLoading ? (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
</div>
|
|
) : selectedRequest && (
|
|
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">상태</span>
|
|
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">진행</span>
|
|
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}단계</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">대상 테이블</span>
|
|
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">요청일</span>
|
|
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
{selectedRequest.description && (
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">사유</span>
|
|
<p className="mt-1 text-sm">{selectedRequest.description}</p>
|
|
</div>
|
|
)}
|
|
{/* 결재선 */}
|
|
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">결재선</span>
|
|
<div className="mt-2 space-y-2">
|
|
{selectedRequest.lines
|
|
.sort((a, b) => a.step_order - b.step_order)
|
|
.map((line) => (
|
|
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-[10px]">{line.step_order}차</Badge>
|
|
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
|
|
{line.approver_position && (
|
|
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={line.status} />
|
|
{line.processed_at && (
|
|
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
{selectedRequest?.status === "requested" && (
|
|
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
결재 회수
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
닫기
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 수신함 (내가 결재해야 할 것)
|
|
// ============================================================
|
|
function ReceivedTab() {
|
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [processOpen, setProcessOpen] = useState(false);
|
|
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
|
|
const [comment, setComment] = useState("");
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
const fetchPending = useCallback(async () => {
|
|
setLoading(true);
|
|
const res = await getMyPendingApprovals();
|
|
if (res.success && res.data) setPendingLines(res.data);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchPending(); }, [fetchPending]);
|
|
|
|
const openProcess = (line: ApprovalLine) => {
|
|
setSelectedLine(line);
|
|
setComment("");
|
|
setProcessOpen(true);
|
|
};
|
|
|
|
const handleProcess = async (action: "approved" | "rejected") => {
|
|
if (!selectedLine) return;
|
|
setIsProcessing(true);
|
|
const res = await processApprovalLine(selectedLine.line_id, {
|
|
action,
|
|
comment: comment.trim() || undefined,
|
|
});
|
|
setIsProcessing(false);
|
|
if (res.success) {
|
|
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
|
|
setProcessOpen(false);
|
|
fetchPending();
|
|
} else {
|
|
toast.error(res.error || "처리 실패");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : pendingLines.length === 0 ? (
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
|
|
<p className="text-muted-foreground text-sm">결재 대기 건이 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-card rounded-lg border shadow-sm">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">요청자</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">단계</TableHead>
|
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">처리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pendingLines.map((line) => (
|
|
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
|
|
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
|
|
<TableCell className="h-14 text-sm">
|
|
{line.requester_name || "-"}
|
|
{line.requester_dept && (
|
|
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
|
|
<TableCell className="h-14 text-center text-sm">
|
|
<Badge variant="outline">{line.step_order}차</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
|
|
<TableCell className="h-14 text-center">
|
|
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
|
|
결재하기
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 결재 처리 모달 */}
|
|
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">결재 처리</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{selectedLine?.title}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">요청자</span>
|
|
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs">결재 단계</span>
|
|
<p className="mt-1 font-medium">{selectedLine?.step_order}차 결재</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">의견</Label>
|
|
<Textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
placeholder="결재 의견을 입력하세요 (선택사항)"
|
|
className="min-h-[80px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => handleProcess("rejected")}
|
|
disabled={isProcessing}
|
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<XCircle className="h-4 w-4" />
|
|
반려
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleProcess("approved")}
|
|
disabled={isProcessing}
|
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isProcessing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle className="h-4 w-4" />
|
|
)}
|
|
승인
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 대결 설정
|
|
// ============================================================
|
|
|
|
interface UserSearchResult {
|
|
userId: string;
|
|
userName: string;
|
|
positionName?: string;
|
|
deptName?: string;
|
|
}
|
|
|
|
function formatDateOnly(dateStr?: string) {
|
|
if (!dateStr) return "-";
|
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
});
|
|
}
|
|
|
|
function ProxyTab() {
|
|
const [proxies, setProxies] = useState<ApprovalProxySetting[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// 등록/수정 모달 상태
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingProxy, setEditingProxy] = useState<ApprovalProxySetting | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// 폼 필드
|
|
const [formOriginalUserId, setFormOriginalUserId] = useState("");
|
|
const [formOriginalUserLabel, setFormOriginalUserLabel] = useState("");
|
|
const [formProxyUserId, setFormProxyUserId] = useState("");
|
|
const [formProxyUserLabel, setFormProxyUserLabel] = useState("");
|
|
const [formStartDate, setFormStartDate] = useState("");
|
|
const [formEndDate, setFormEndDate] = useState("");
|
|
const [formReason, setFormReason] = useState("");
|
|
const [formIsActive, setFormIsActive] = useState(true);
|
|
|
|
// 사용자 검색 상태 (원래 결재자)
|
|
const [origSearchQuery, setOrigSearchQuery] = useState("");
|
|
const [origSearchResults, setOrigSearchResults] = useState<UserSearchResult[]>([]);
|
|
const [origSearchOpen, setOrigSearchOpen] = useState(false);
|
|
const [origSearching, setOrigSearching] = useState(false);
|
|
const origTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 사용자 검색 상태 (대결자)
|
|
const [proxySearchQuery, setProxySearchQuery] = useState("");
|
|
const [proxySearchResults, setProxySearchResults] = useState<UserSearchResult[]>([]);
|
|
const [proxySearchOpen, setProxySearchOpen] = useState(false);
|
|
const [proxySearching, setProxySearching] = useState(false);
|
|
const proxyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 삭제 확인 모달
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const fetchProxies = useCallback(async () => {
|
|
setLoading(true);
|
|
const res = await getProxySettings();
|
|
if (res.success && res.data) setProxies(res.data);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchProxies(); }, [fetchProxies]);
|
|
|
|
// 사용자 검색 공통 로직
|
|
const searchUsers = useCallback(async (
|
|
query: string,
|
|
setResults: (r: UserSearchResult[]) => void,
|
|
setSearching: (b: boolean) => void,
|
|
) => {
|
|
if (!query.trim() || query.trim().length < 1) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
setSearching(true);
|
|
try {
|
|
const res = await getUserList({ search: query.trim(), limit: 20 });
|
|
const data = res?.data || res || [];
|
|
const rawUsers: any[] = Array.isArray(data) ? data : [];
|
|
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
|
|
userId: u.userId || u.user_id || "",
|
|
userName: u.userName || u.user_name || "",
|
|
positionName: u.positionName || u.position_name || "",
|
|
deptName: u.deptName || u.dept_name || "",
|
|
}));
|
|
setResults(users);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
// 원래 결재자 검색 디바운스
|
|
useEffect(() => {
|
|
if (origTimerRef.current) clearTimeout(origTimerRef.current);
|
|
if (!origSearchQuery.trim()) { setOrigSearchResults([]); return; }
|
|
origTimerRef.current = setTimeout(() => {
|
|
searchUsers(origSearchQuery, setOrigSearchResults, setOrigSearching);
|
|
}, 300);
|
|
return () => { if (origTimerRef.current) clearTimeout(origTimerRef.current); };
|
|
}, [origSearchQuery, searchUsers]);
|
|
|
|
// 대결자 검색 디바운스
|
|
useEffect(() => {
|
|
if (proxyTimerRef.current) clearTimeout(proxyTimerRef.current);
|
|
if (!proxySearchQuery.trim()) { setProxySearchResults([]); return; }
|
|
proxyTimerRef.current = setTimeout(() => {
|
|
searchUsers(proxySearchQuery, setProxySearchResults, setProxySearching);
|
|
}, 300);
|
|
return () => { if (proxyTimerRef.current) clearTimeout(proxyTimerRef.current); };
|
|
}, [proxySearchQuery, searchUsers]);
|
|
|
|
const resetForm = () => {
|
|
setFormOriginalUserId("");
|
|
setFormOriginalUserLabel("");
|
|
setFormProxyUserId("");
|
|
setFormProxyUserLabel("");
|
|
setFormStartDate("");
|
|
setFormEndDate("");
|
|
setFormReason("");
|
|
setFormIsActive(true);
|
|
setOrigSearchQuery("");
|
|
setOrigSearchResults([]);
|
|
setOrigSearchOpen(false);
|
|
setProxySearchQuery("");
|
|
setProxySearchResults([]);
|
|
setProxySearchOpen(false);
|
|
setEditingProxy(null);
|
|
};
|
|
|
|
const openCreate = () => {
|
|
resetForm();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (proxy: ApprovalProxySetting) => {
|
|
setEditingProxy(proxy);
|
|
setFormOriginalUserId(proxy.original_user_id);
|
|
setFormOriginalUserLabel(
|
|
proxy.original_user_name
|
|
? `${proxy.original_user_name}${proxy.original_dept_name ? ` (${proxy.original_dept_name})` : ""}`
|
|
: proxy.original_user_id
|
|
);
|
|
setFormProxyUserId(proxy.proxy_user_id);
|
|
setFormProxyUserLabel(
|
|
proxy.proxy_user_name
|
|
? `${proxy.proxy_user_name}${proxy.proxy_dept_name ? ` (${proxy.proxy_dept_name})` : ""}`
|
|
: proxy.proxy_user_id
|
|
);
|
|
setFormStartDate(proxy.start_date?.split("T")[0] || "");
|
|
setFormEndDate(proxy.end_date?.split("T")[0] || "");
|
|
setFormReason(proxy.reason || "");
|
|
setFormIsActive(proxy.is_active === "Y");
|
|
setOrigSearchQuery("");
|
|
setOrigSearchResults([]);
|
|
setOrigSearchOpen(false);
|
|
setProxySearchQuery("");
|
|
setProxySearchResults([]);
|
|
setProxySearchOpen(false);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formOriginalUserId) { toast.error("원래 결재자를 선택해주세요."); return; }
|
|
if (!formProxyUserId) { toast.error("대결자를 선택해주세요."); return; }
|
|
if (!formStartDate) { toast.error("시작일을 입력해주세요."); return; }
|
|
if (!formEndDate) { toast.error("종료일을 입력해주세요."); return; }
|
|
if (formStartDate > formEndDate) { toast.error("종료일은 시작일 이후여야 합니다."); return; }
|
|
if (formOriginalUserId === formProxyUserId) { toast.error("원래 결재자와 대결자가 같을 수 없습니다."); return; }
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
if (editingProxy) {
|
|
const res = await updateProxySetting(editingProxy.id, {
|
|
proxy_user_id: formProxyUserId,
|
|
start_date: formStartDate,
|
|
end_date: formEndDate,
|
|
reason: formReason.trim() || undefined,
|
|
is_active: formIsActive ? "Y" : "N",
|
|
});
|
|
if (res.success) {
|
|
toast.success("대결 설정이 수정되었습니다.");
|
|
setModalOpen(false);
|
|
resetForm();
|
|
fetchProxies();
|
|
} else {
|
|
toast.error(res.error || "수정 실패");
|
|
}
|
|
} else {
|
|
const res = await createProxySetting({
|
|
original_user_id: formOriginalUserId,
|
|
proxy_user_id: formProxyUserId,
|
|
start_date: formStartDate,
|
|
end_date: formEndDate,
|
|
reason: formReason.trim() || undefined,
|
|
});
|
|
if (res.success) {
|
|
toast.success("대결 설정이 등록되었습니다.");
|
|
setModalOpen(false);
|
|
resetForm();
|
|
fetchProxies();
|
|
} else {
|
|
toast.error(res.error || "등록 실패");
|
|
}
|
|
}
|
|
} catch {
|
|
toast.error("요청 처리 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (id: number) => {
|
|
setDeletingId(id);
|
|
setDeleteConfirmOpen(true);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deletingId) return;
|
|
setIsDeleting(true);
|
|
const res = await deleteProxySetting(deletingId);
|
|
setIsDeleting(false);
|
|
if (res.success) {
|
|
toast.success("대결 설정이 삭제되었습니다.");
|
|
setDeleteConfirmOpen(false);
|
|
setDeletingId(null);
|
|
fetchProxies();
|
|
} else {
|
|
toast.error(res.error || "삭제 실패");
|
|
}
|
|
};
|
|
|
|
const selectOrigUser = (user: UserSearchResult) => {
|
|
setFormOriginalUserId(user.userId);
|
|
setFormOriginalUserLabel(`${user.userName}${user.deptName ? ` (${user.deptName})` : ""}`);
|
|
setOrigSearchOpen(false);
|
|
setOrigSearchQuery("");
|
|
setOrigSearchResults([]);
|
|
};
|
|
|
|
const selectProxyUser = (user: UserSearchResult) => {
|
|
setFormProxyUserId(user.userId);
|
|
setFormProxyUserLabel(`${user.userName}${user.deptName ? ` (${user.deptName})` : ""}`);
|
|
setProxySearchOpen(false);
|
|
setProxySearchQuery("");
|
|
setProxySearchResults([]);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 상단 액션 */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-muted-foreground text-sm">
|
|
결재자가 부재 시 대결자가 대신 결재를 처리합니다.
|
|
</p>
|
|
<Button onClick={openCreate} className="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />
|
|
대결 등록
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 목록 */}
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : proxies.length === 0 ? (
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
<UserCog className="text-muted-foreground mb-2 h-8 w-8" />
|
|
<p className="text-muted-foreground text-sm">등록된 대결 설정이 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-card rounded-lg border shadow-sm">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
|
<TableHead className="h-12 text-sm font-semibold">원래 결재자</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">대결자</TableHead>
|
|
<TableHead className="h-12 w-[120px] text-sm font-semibold">시작일</TableHead>
|
|
<TableHead className="h-12 w-[120px] text-sm font-semibold">종료일</TableHead>
|
|
<TableHead className="h-12 text-sm font-semibold">사유</TableHead>
|
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">활성</TableHead>
|
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{proxies.map((proxy) => (
|
|
<TableRow key={proxy.id} className="border-b transition-colors hover:bg-muted/50">
|
|
<TableCell className="h-14 text-sm">
|
|
<span className="font-medium">{proxy.original_user_name || proxy.original_user_id}</span>
|
|
{proxy.original_dept_name && (
|
|
<span className="text-muted-foreground ml-1 text-xs">({proxy.original_dept_name})</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="h-14 text-sm">
|
|
<span className="font-medium">{proxy.proxy_user_name || proxy.proxy_user_id}</span>
|
|
{proxy.proxy_dept_name && (
|
|
<span className="text-muted-foreground ml-1 text-xs">({proxy.proxy_dept_name})</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">
|
|
{formatDateOnly(proxy.start_date)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">
|
|
{formatDateOnly(proxy.end_date)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground h-14 text-sm">
|
|
{proxy.reason || "-"}
|
|
</TableCell>
|
|
<TableCell className="h-14 text-center">
|
|
<Badge variant={proxy.is_active === "Y" ? "default" : "secondary"}>
|
|
{proxy.is_active === "Y" ? "활성" : "비활성"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="h-14 text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(proxy)}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => confirmDelete(proxy.id)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 등록/수정 모달 */}
|
|
<Dialog open={modalOpen} onOpenChange={(open) => { if (!open) { resetForm(); } setModalOpen(open); }}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{editingProxy ? "대결 설정 수정" : "대결 설정 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
결재자 부재 시 대신 결재할 대결자를 설정합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* 원래 결재자 */}
|
|
<div>
|
|
<Label htmlFor="originalUser" className="text-xs sm:text-sm">
|
|
원래 결재자 <span className="text-destructive">*</span>
|
|
</Label>
|
|
{editingProxy ? (
|
|
<Input
|
|
value={formOriginalUserLabel}
|
|
disabled
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
) : (
|
|
<div className="relative mt-1">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
id="originalUser"
|
|
value={formOriginalUserId ? formOriginalUserLabel : origSearchQuery}
|
|
onChange={(e) => {
|
|
if (formOriginalUserId) {
|
|
setFormOriginalUserId("");
|
|
setFormOriginalUserLabel("");
|
|
}
|
|
setOrigSearchQuery(e.target.value);
|
|
setOrigSearchOpen(true);
|
|
}}
|
|
onFocus={() => { if (origSearchQuery.trim()) setOrigSearchOpen(true); }}
|
|
placeholder="이름 또는 ID로 검색"
|
|
className="h-8 pl-10 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
{origSearchOpen && (origSearchResults.length > 0 || origSearching) && (
|
|
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-lg">
|
|
{origSearching ? (
|
|
<div className="flex items-center justify-center p-3">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{origSearchResults.map((user) => (
|
|
<div
|
|
key={user.userId}
|
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center gap-2 px-3 py-2 text-sm"
|
|
onClick={() => selectOrigUser(user)}
|
|
>
|
|
<span className="font-medium">{user.userName}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{user.userId}
|
|
{user.deptName ? ` / ${user.deptName}` : ""}
|
|
{user.positionName ? ` / ${user.positionName}` : ""}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 대결자 */}
|
|
<div>
|
|
<Label htmlFor="proxyUser" className="text-xs sm:text-sm">
|
|
대결자 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<div className="relative mt-1">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
id="proxyUser"
|
|
value={formProxyUserId ? formProxyUserLabel : proxySearchQuery}
|
|
onChange={(e) => {
|
|
if (formProxyUserId) {
|
|
setFormProxyUserId("");
|
|
setFormProxyUserLabel("");
|
|
}
|
|
setProxySearchQuery(e.target.value);
|
|
setProxySearchOpen(true);
|
|
}}
|
|
onFocus={() => { if (proxySearchQuery.trim()) setProxySearchOpen(true); }}
|
|
placeholder="이름 또는 ID로 검색"
|
|
className="h-8 pl-10 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
{proxySearchOpen && (proxySearchResults.length > 0 || proxySearching) && (
|
|
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-lg">
|
|
{proxySearching ? (
|
|
<div className="flex items-center justify-center p-3">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="max-h-48 overflow-y-auto">
|
|
{proxySearchResults.map((user) => (
|
|
<div
|
|
key={user.userId}
|
|
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center gap-2 px-3 py-2 text-sm"
|
|
onClick={() => selectProxyUser(user)}
|
|
>
|
|
<span className="font-medium">{user.userName}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{user.userId}
|
|
{user.deptName ? ` / ${user.deptName}` : ""}
|
|
{user.positionName ? ` / ${user.positionName}` : ""}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 시작일 / 종료일 */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label htmlFor="startDate" className="text-xs sm:text-sm">
|
|
시작일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="startDate"
|
|
type="date"
|
|
value={formStartDate}
|
|
onChange={(e) => setFormStartDate(e.target.value)}
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="endDate" className="text-xs sm:text-sm">
|
|
종료일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="endDate"
|
|
type="date"
|
|
value={formEndDate}
|
|
onChange={(e) => setFormEndDate(e.target.value)}
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 사유 */}
|
|
<div>
|
|
<Label htmlFor="reason" className="text-xs sm:text-sm">사유</Label>
|
|
<Textarea
|
|
id="reason"
|
|
value={formReason}
|
|
onChange={(e) => setFormReason(e.target.value)}
|
|
placeholder="대결 사유를 입력하세요 (선택사항)"
|
|
className="mt-1 min-h-[60px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* 활성 여부 (수정 시에만 표시) */}
|
|
{editingProxy && (
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="isActive" className="text-xs sm:text-sm">활성 여부</Label>
|
|
<Switch
|
|
id="isActive"
|
|
checked={formIsActive}
|
|
onCheckedChange={setFormIsActive}
|
|
aria-label="활성 여부 토글"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { resetForm(); setModalOpen(false); }}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{editingProxy ? "수정" : "등록"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">대결 설정 삭제</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
정말로 이 대결 설정을 삭제하시겠습니까?
|
|
<br />삭제 후 복구할 수 없습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { setDeleteConfirmOpen(false); setDeletingId(null); }}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
삭제
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 메인 페이지
|
|
// ============================================================
|
|
export default function ApprovalBoxPage() {
|
|
return (
|
|
<div className="bg-background flex min-h-screen flex-col">
|
|
<div className="space-y-6 p-6">
|
|
<div className="space-y-2 border-b pb-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">결재함</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
내가 상신한 결재와 나에게 온 결재를 관리합니다.
|
|
</p>
|
|
</div>
|
|
|
|
<Tabs defaultValue="received" className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="received" className="gap-2">
|
|
<Inbox className="h-4 w-4" />
|
|
수신함 (결재 대기)
|
|
</TabsTrigger>
|
|
<TabsTrigger value="sent" className="gap-2">
|
|
<Send className="h-4 w-4" />
|
|
상신함 (내가 올린)
|
|
</TabsTrigger>
|
|
<TabsTrigger value="proxy" className="gap-2">
|
|
<UserCog className="h-4 w-4" />
|
|
대결 설정
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="received">
|
|
<ReceivedTab />
|
|
</TabsContent>
|
|
<TabsContent value="sent">
|
|
<SentTab />
|
|
</TabsContent>
|
|
<TabsContent value="proxy">
|
|
<ProxyTab />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|