[agent-pipeline] pipe-20260305133525-uca5 round-4
This commit is contained in:
parent
7d6ca6403a
commit
e662de1da4
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
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";
|
||||
|
|
@ -12,9 +12,12 @@ import {
|
|||
} 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, Clock, Eye,
|
||||
UserCog, Plus, Pencil, Trash2, Search,
|
||||
} from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
|
|
@ -23,9 +26,15 @@ import {
|
|||
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" },
|
||||
|
|
@ -378,6 +387,574 @@ function ReceivedTab() {
|
|||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 대결 설정
|
||||
// ============================================================
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 페이지
|
||||
// ============================================================
|
||||
|
|
@ -402,6 +979,10 @@ export default function ApprovalBoxPage() {
|
|||
<Send className="h-4 w-4" />
|
||||
상신함 (내가 올린)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy" className="gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
대결 설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="received">
|
||||
|
|
@ -410,6 +991,9 @@ export default function ApprovalBoxPage() {
|
|||
<TabsContent value="sent">
|
||||
<SentTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="proxy">
|
||||
<ProxyTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,956 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
type ApprovalDefinition,
|
||||
type ApprovalLineTemplate,
|
||||
type ApprovalLineTemplateStep,
|
||||
getApprovalDefinitions,
|
||||
getApprovalTemplates,
|
||||
getApprovalTemplate,
|
||||
createApprovalTemplate,
|
||||
updateApprovalTemplate,
|
||||
deleteApprovalTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
type StepType = "approval" | "consensus" | "notification";
|
||||
|
||||
interface StepApprover {
|
||||
approver_type: "user" | "position" | "dept";
|
||||
approver_user_id?: string;
|
||||
approver_position?: string;
|
||||
approver_dept_code?: string;
|
||||
approver_label?: string;
|
||||
}
|
||||
|
||||
interface StepFormData {
|
||||
step_order: number;
|
||||
step_type: StepType;
|
||||
approvers: StepApprover[];
|
||||
}
|
||||
|
||||
interface TemplateFormData {
|
||||
template_name: string;
|
||||
description: string;
|
||||
definition_id: number | null;
|
||||
steps: StepFormData[];
|
||||
}
|
||||
|
||||
const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [
|
||||
{ value: "approval", label: "결재" },
|
||||
{ value: "consensus", label: "합의" },
|
||||
{ value: "notification", label: "통보" },
|
||||
];
|
||||
|
||||
const STEP_TYPE_BADGE: Record<StepType, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
approval: { label: "결재", variant: "default" },
|
||||
consensus: { label: "합의", variant: "secondary" },
|
||||
notification: { label: "통보", variant: "outline" },
|
||||
};
|
||||
|
||||
const INITIAL_FORM: TemplateFormData = {
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null,
|
||||
steps: [
|
||||
{
|
||||
step_order: 1,
|
||||
step_type: "approval",
|
||||
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 사용자 검색 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
function UserSearchInput({
|
||||
value,
|
||||
label,
|
||||
onSelect,
|
||||
onLabelChange,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
onSelect: (userId: string, userName: string) => void;
|
||||
onLabelChange: (label: string) => void;
|
||||
}) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (text: string) => {
|
||||
setSearchText(text);
|
||||
if (text.length < 1) {
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await getUserList({ search: text, limit: 10 });
|
||||
const users = res?.success !== false ? (res?.data || res || []) : [];
|
||||
setResults(Array.isArray(users) ? users : []);
|
||||
setShowResults(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectUser = (user: any) => {
|
||||
const userId = user.user_id || user.userId || "";
|
||||
const userName = user.user_name || user.userName || userId;
|
||||
onSelect(userId, userName);
|
||||
setSearchText("");
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">결재자 ID</Label>
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
value={value || searchText}
|
||||
onChange={(e) => {
|
||||
if (value) {
|
||||
onSelect("", "");
|
||||
}
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
placeholder="ID 또는 이름 검색"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
{showResults && results.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-40 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
|
||||
{results.map((user, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||
onClick={() => selectUser(user)}
|
||||
>
|
||||
<span className="font-medium">{user.user_name || user.userName}</span>
|
||||
<span className="text-muted-foreground">({user.user_id || user.userId})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showResults && results.length === 0 && !searching && searchText.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-2 text-center text-xs text-muted-foreground shadow-md">
|
||||
검색 결과 없음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="예: 팀장"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 단계 편집 행 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
function StepEditor({
|
||||
step,
|
||||
stepIndex,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
step: StepFormData;
|
||||
stepIndex: number;
|
||||
onUpdate: (stepIndex: number, updated: StepFormData) => void;
|
||||
onRemove: (stepIndex: number) => void;
|
||||
}) {
|
||||
const updateStepType = (newType: StepType) => {
|
||||
const updated = { ...step, step_type: newType };
|
||||
if (newType === "notification" && updated.approvers.length > 1) {
|
||||
updated.approvers = [updated.approvers[0]];
|
||||
}
|
||||
onUpdate(stepIndex, updated);
|
||||
};
|
||||
|
||||
const addApprover = () => {
|
||||
onUpdate(stepIndex, {
|
||||
...step,
|
||||
approvers: [
|
||||
...step.approvers,
|
||||
{ approver_type: "user", approver_user_id: "", approver_label: "" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeApprover = (approverIdx: number) => {
|
||||
if (step.approvers.length <= 1) return;
|
||||
onUpdate(stepIndex, {
|
||||
...step,
|
||||
approvers: step.approvers.filter((_, i) => i !== approverIdx),
|
||||
});
|
||||
};
|
||||
|
||||
const updateApprover = (approverIdx: number, field: string, value: string) => {
|
||||
onUpdate(stepIndex, {
|
||||
...step,
|
||||
approvers: step.approvers.map((a, i) =>
|
||||
i === approverIdx ? { ...a, [field]: value } : a,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserSelect = (approverIdx: number, userId: string, userName: string) => {
|
||||
onUpdate(stepIndex, {
|
||||
...step,
|
||||
approvers: step.approvers.map((a, i) =>
|
||||
i === approverIdx
|
||||
? { ...a, approver_user_id: userId, approver_label: a.approver_label || userName }
|
||||
: a,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const badgeInfo = STEP_TYPE_BADGE[step.step_type];
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
|
||||
{/* 단계 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold">{step.step_order}단계</span>
|
||||
<Badge variant={badgeInfo.variant} className="text-[10px]">
|
||||
{badgeInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => onRemove(stepIndex)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* step_type 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">결재 유형</Label>
|
||||
<Select value={step.step_type} onValueChange={(v) => updateStepType(v as StepType)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통보 타입 안내 */}
|
||||
{step.step_type === "notification" && (
|
||||
<p className="text-[10px] text-muted-foreground italic">
|
||||
(자동 처리됩니다 - 통보 대상자에게 알림만 발송)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 결재자 목록 */}
|
||||
<div className="space-y-2">
|
||||
{step.approvers.map((approver, aIdx) => (
|
||||
<div key={aIdx} className="rounded border bg-background p-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{step.step_type === "consensus"
|
||||
? `합의자 ${aIdx + 1}`
|
||||
: step.step_type === "notification"
|
||||
? "통보 대상"
|
||||
: "결재자"}
|
||||
</span>
|
||||
{step.approvers.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-destructive"
|
||||
onClick={() => removeApprover(aIdx)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">결재자 유형</Label>
|
||||
<Select
|
||||
value={approver.approver_type}
|
||||
onValueChange={(v) => updateApprover(aIdx, "approver_type", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user" className="text-xs">사용자 지정</SelectItem>
|
||||
<SelectItem value="position" className="text-xs">직급 지정</SelectItem>
|
||||
<SelectItem value="dept" className="text-xs">부서 지정</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{approver.approver_type === "user" && (
|
||||
<UserSearchInput
|
||||
value={approver.approver_user_id || ""}
|
||||
label={approver.approver_label || ""}
|
||||
onSelect={(userId, userName) => handleUserSelect(aIdx, userId, userName)}
|
||||
onLabelChange={(label) => updateApprover(aIdx, "approver_label", label)}
|
||||
/>
|
||||
)}
|
||||
{approver.approver_type === "position" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">직급</Label>
|
||||
<Input
|
||||
value={approver.approver_position || ""}
|
||||
onChange={(e) => updateApprover(aIdx, "approver_position", e.target.value)}
|
||||
placeholder="예: 부장, 이사"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={approver.approver_label || ""}
|
||||
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
|
||||
placeholder="예: 팀장"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{approver.approver_type === "dept" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">부서 코드</Label>
|
||||
<Input
|
||||
value={approver.approver_dept_code || ""}
|
||||
onChange={(e) => updateApprover(aIdx, "approver_dept_code", e.target.value)}
|
||||
placeholder="예: DEPT001"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={approver.approver_label || ""}
|
||||
onChange={(e) => updateApprover(aIdx, "approver_label", e.target.value)}
|
||||
placeholder="예: 경영지원팀"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 합의 타입일 때만 결재자 추가 버튼 */}
|
||||
{step.step_type === "consensus" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addApprover}
|
||||
className="h-6 w-full gap-1 text-[10px]"
|
||||
>
|
||||
<UserPlus className="h-3 w-3" />
|
||||
합의자 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 페이지
|
||||
// ============================================================
|
||||
|
||||
export default function ApprovalTemplatePage() {
|
||||
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingTpl, setEditingTpl] = useState<ApprovalLineTemplate | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<TemplateFormData>({ ...INITIAL_FORM });
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApprovalLineTemplate | null>(null);
|
||||
|
||||
// ---- 데이터 로딩 ----
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [tplRes, defRes] = await Promise.all([
|
||||
getApprovalTemplates(),
|
||||
getApprovalDefinitions({ is_active: "Y" }),
|
||||
]);
|
||||
if (tplRes.success && tplRes.data) setTemplates(tplRes.data);
|
||||
if (defRes.success && defRes.data) setDefinitions(defRes.data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ---- 템플릿 등록/수정에서 steps를 StepFormData로 변환 ----
|
||||
const stepsToFormData = (steps: ApprovalLineTemplateStep[]): StepFormData[] => {
|
||||
const stepMap = new Map<number, StepFormData>();
|
||||
|
||||
const sorted = [...steps].sort((a, b) => a.step_order - b.step_order);
|
||||
for (const s of sorted) {
|
||||
const existing = stepMap.get(s.step_order);
|
||||
const approver: StepApprover = {
|
||||
approver_type: s.approver_type,
|
||||
approver_user_id: s.approver_user_id,
|
||||
approver_position: s.approver_position,
|
||||
approver_dept_code: s.approver_dept_code,
|
||||
approver_label: s.approver_label,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.approvers.push(approver);
|
||||
if (s.step_type) existing.step_type = s.step_type;
|
||||
} else {
|
||||
stepMap.set(s.step_order, {
|
||||
step_order: s.step_order,
|
||||
step_type: s.step_type || "approval",
|
||||
approvers: [approver],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(stepMap.values()).sort((a, b) => a.step_order - b.step_order);
|
||||
};
|
||||
|
||||
// ---- StepFormData를 API payload로 변환 ----
|
||||
const formDataToSteps = (
|
||||
steps: StepFormData[],
|
||||
): Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] => {
|
||||
const result: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[] = [];
|
||||
for (const step of steps) {
|
||||
for (const approver of step.approvers) {
|
||||
result.push({
|
||||
step_order: step.step_order,
|
||||
step_type: step.step_type,
|
||||
approver_type: approver.approver_type,
|
||||
approver_user_id: approver.approver_user_id || undefined,
|
||||
approver_position: approver.approver_position || undefined,
|
||||
approver_dept_code: approver.approver_dept_code || undefined,
|
||||
approver_label: approver.approver_label || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---- 모달 열기 ----
|
||||
const openCreate = () => {
|
||||
setEditingTpl(null);
|
||||
setFormData({
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null,
|
||||
steps: [
|
||||
{
|
||||
step_order: 1,
|
||||
step_type: "approval",
|
||||
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = async (tpl: ApprovalLineTemplate) => {
|
||||
const res = await getApprovalTemplate(tpl.template_id);
|
||||
if (!res.success || !res.data) {
|
||||
toast.error("템플릿 정보를 불러올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
const detail = res.data;
|
||||
setEditingTpl(detail);
|
||||
setFormData({
|
||||
template_name: detail.template_name,
|
||||
description: detail.description || "",
|
||||
definition_id: detail.definition_id || null,
|
||||
steps:
|
||||
detail.steps && detail.steps.length > 0
|
||||
? stepsToFormData(detail.steps)
|
||||
: [
|
||||
{
|
||||
step_order: 1,
|
||||
step_type: "approval",
|
||||
approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
// ---- 단계 관리 ----
|
||||
const addStep = () => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: [
|
||||
...p.steps,
|
||||
{
|
||||
step_order: p.steps.length + 1,
|
||||
step_type: "approval",
|
||||
approvers: [
|
||||
{
|
||||
approver_type: "user",
|
||||
approver_user_id: "",
|
||||
approver_label: `${p.steps.length + 1}차 결재자`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: p.steps
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((s, i) => ({ ...s, step_order: i + 1 })),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateStep = (idx: number, updated: StepFormData) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: p.steps.map((s, i) => (i === idx ? updated : s)),
|
||||
}));
|
||||
};
|
||||
|
||||
// ---- 저장 ----
|
||||
const handleSave = async () => {
|
||||
if (!formData.template_name.trim()) {
|
||||
toast.warning("템플릿명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (formData.steps.length === 0) {
|
||||
toast.warning("결재 단계를 최소 1개 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEmptyApprover = formData.steps.some((step) =>
|
||||
step.approvers.some((a) => {
|
||||
if (a.approver_type === "user" && !a.approver_user_id) return true;
|
||||
if (a.approver_type === "position" && !a.approver_position) return true;
|
||||
if (a.approver_type === "dept" && !a.approver_dept_code) return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
if (hasEmptyApprover) {
|
||||
toast.warning("모든 결재자 정보를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
template_name: formData.template_name,
|
||||
description: formData.description || undefined,
|
||||
definition_id: formData.definition_id || undefined,
|
||||
steps: formDataToSteps(formData.steps),
|
||||
};
|
||||
|
||||
let res;
|
||||
if (editingTpl) {
|
||||
res = await updateApprovalTemplate(editingTpl.template_id, payload);
|
||||
} else {
|
||||
res = await createApprovalTemplate(payload);
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
if (res.success) {
|
||||
toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다.");
|
||||
setEditOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(res.error || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 삭제 ----
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const res = await deleteApprovalTemplate(deleteTarget.template_id);
|
||||
if (res.success) {
|
||||
toast.success("삭제되었습니다.");
|
||||
setDeleteTarget(null);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(res.error || "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 필터 ----
|
||||
const filtered = templates.filter(
|
||||
(t) =>
|
||||
t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(t.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// ---- 단계 요약 뱃지 생성 ----
|
||||
const renderStepSummary = (tpl: ApprovalLineTemplate) => {
|
||||
if (!tpl.steps || tpl.steps.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
|
||||
const stepMap = new Map<number, { type: StepType; count: number }>();
|
||||
for (const s of tpl.steps) {
|
||||
const existing = stepMap.get(s.step_order);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
stepMap.set(s.step_order, { type: s.step_type || "approval", count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(stepMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([order, info]) => {
|
||||
const badge = STEP_TYPE_BADGE[info.type];
|
||||
return (
|
||||
<Badge key={order} variant={badge.variant} className="text-[10px]">
|
||||
{order}단계 {badge.label}
|
||||
{info.count > 1 && ` (${info.count}명)`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- 날짜 포맷 ----
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 검색 + 신규 등록 버튼 */}
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="템플릿명 또는 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
총 <span className="text-foreground font-semibold">{filtered.length}</span> 건
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<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-[120px] text-sm font-semibold">연결된 유형</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((tpl) => (
|
||||
<TableRow key={tpl.template_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{tpl.template_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">
|
||||
{tpl.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-sm">{renderStepSummary(tpl)}</TableCell>
|
||||
<TableCell className="h-14 text-sm">{tpl.definition_name || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
{formatDate(tpl.created_at)}
|
||||
</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(tpl)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteTarget(tpl)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingTpl ? "결재 템플릿 수정" : "결재 템플릿 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
결재선의 기본 정보와 단계별 결재자를 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
||||
{/* 템플릿 기본 정보 */}
|
||||
<div>
|
||||
<Label htmlFor="template_name" className="text-xs sm:text-sm">
|
||||
템플릿 이름 *
|
||||
</Label>
|
||||
<Input
|
||||
id="template_name"
|
||||
value={formData.template_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, template_name: e.target.value }))}
|
||||
placeholder="예: 일반 3단계 결재선"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="템플릿에 대한 설명을 입력하세요"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 유형 연결</Label>
|
||||
<Select
|
||||
value={formData.definition_id ? String(formData.definition_id) : "none"}
|
||||
onValueChange={(v) =>
|
||||
setFormData((p) => ({ ...p, definition_id: v === "none" ? null : Number(v) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="결재 유형 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">연결 없음</SelectItem>
|
||||
{definitions.map((d) => (
|
||||
<SelectItem key={d.definition_id} value={String(d.definition_id)}>
|
||||
{d.definition_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
특정 결재 유형에 이 템플릿을 연결할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재 단계 편집 영역 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">결재 단계</Label>
|
||||
<Button variant="outline" size="sm" onClick={addStep} className="h-7 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
단계 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.steps.length === 0 && (
|
||||
<p className="text-muted-foreground py-4 text-center text-xs">
|
||||
결재 단계를 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formData.steps.map((step, idx) => (
|
||||
<StepEditor
|
||||
key={`step-${idx}-${step.step_order}`}
|
||||
step={step}
|
||||
stepIndex={idx}
|
||||
onUpdate={updateStep}
|
||||
onRemove={removeStep}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingTpl ? "수정" : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재 템플릿 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,20 +9,40 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createApprovalRequest } from "@/lib/api/approval";
|
||||
import {
|
||||
createApprovalRequest,
|
||||
getApprovalTemplates,
|
||||
getTemplateSteps,
|
||||
type ApprovalLineTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
|
||||
// 결재 방식
|
||||
type ApprovalMode = "sequential" | "parallel";
|
||||
|
||||
// 결재 유형
|
||||
type ApprovalType = "self" | "escalation" | "consensus" | "post";
|
||||
|
||||
// step_type 라벨 매핑
|
||||
const STEP_TYPE_LABEL: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
approval: { label: "결재", variant: "default" },
|
||||
consensus: { label: "합의", variant: "secondary" },
|
||||
notification: { label: "통보", variant: "outline" },
|
||||
};
|
||||
|
||||
interface ApproverRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
position_name: string;
|
||||
dept_name: string;
|
||||
step_type?: "approval" | "consensus" | "notification";
|
||||
step_order?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalModalEventDetail {
|
||||
|
|
@ -69,6 +89,15 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 결재 유형 상태
|
||||
const [approvalType, setApprovalType] = useState<ApprovalType>("escalation");
|
||||
|
||||
// 템플릿 상태
|
||||
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
|
||||
const [showTemplatePopover, setShowTemplatePopover] = useState(false);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||
|
||||
// 사용자 검색 상태
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -83,14 +112,74 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
setTitle("");
|
||||
setDescription("");
|
||||
setApprovalMode("sequential");
|
||||
setApprovalType("escalation");
|
||||
setApprovers([]);
|
||||
setError(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
setSelectedTemplateId(null);
|
||||
setShowTemplatePopover(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 모달 열릴 때 템플릿 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadTemplates();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const res = await getApprovalTemplates({ is_active: "Y" });
|
||||
if (res.success && res.data) {
|
||||
setTemplates(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 템플릿 로드 실패는 무시 (선택사항이므로)
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 선택 시 결재자 리스트에 반영
|
||||
const applyTemplate = async (templateId: number) => {
|
||||
try {
|
||||
const res = await getTemplateSteps(templateId);
|
||||
if (!res.success || !res.data || res.data.length === 0) {
|
||||
toast.error("템플릿에 설정된 결재 단계가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = res.data;
|
||||
const hasMultipleTypes = new Set(steps.map((s) => s.step_type || "approval")).size > 1;
|
||||
|
||||
// step_type이 혼합이면 escalation으로 설정
|
||||
if (hasMultipleTypes) {
|
||||
setApprovalType("escalation");
|
||||
}
|
||||
|
||||
const newApprovers: ApproverRow[] = steps.map((step) => ({
|
||||
id: genId(),
|
||||
user_id: step.approver_user_id || "",
|
||||
user_name: step.approver_label || step.approver_user_id || "",
|
||||
position_name: step.approver_position || "",
|
||||
dept_name: step.approver_dept_code || "",
|
||||
step_type: step.step_type || "approval",
|
||||
step_order: step.step_order,
|
||||
}));
|
||||
|
||||
setApprovers(newApprovers);
|
||||
setSelectedTemplateId(templateId);
|
||||
setShowTemplatePopover(false);
|
||||
toast.success("템플릿이 적용되었습니다.");
|
||||
} catch {
|
||||
toast.error("템플릿 적용 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 검색 (디바운스)
|
||||
const searchUsers = useCallback(async (query: string) => {
|
||||
if (!query.trim() || query.trim().length < 1) {
|
||||
|
|
@ -169,7 +258,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
setError("결재 제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (approvers.length === 0) {
|
||||
// 자기결재가 아닌 경우 결재자 필수
|
||||
if (approvalType !== "self" && approvers.length === 0) {
|
||||
setError("결재자를 1명 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -181,25 +271,34 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
// 혼합형 여부: approvers에 step_type이 설정된 경우
|
||||
const hasMixedStepTypes = approvers.some((a) => a.step_type);
|
||||
|
||||
const res = await createApprovalRequest({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target_table: eventDetail.targetTable,
|
||||
target_record_id: eventDetail.targetRecordId || undefined,
|
||||
target_record_data: eventDetail.targetRecordData,
|
||||
approval_mode: approvalMode,
|
||||
approval_mode: approvalType === "consensus" ? "parallel" : approvalMode,
|
||||
approval_type: approvalType,
|
||||
screen_id: eventDetail.screenId,
|
||||
button_component_id: eventDetail.buttonComponentId,
|
||||
approvers: approvers.map((a, idx) => ({
|
||||
approver_id: a.user_id,
|
||||
approver_name: a.user_name,
|
||||
approver_position: a.position_name || undefined,
|
||||
approver_dept: a.dept_name || undefined,
|
||||
approver_label:
|
||||
approvalMode === "sequential"
|
||||
? `${idx + 1}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
approvers: approvalType === "self"
|
||||
? []
|
||||
: approvers.map((a, idx) => ({
|
||||
approver_id: a.user_id,
|
||||
approver_name: a.user_name,
|
||||
approver_position: a.position_name || undefined,
|
||||
approver_dept: a.dept_name || undefined,
|
||||
step_type: hasMixedStepTypes ? (a.step_type || "approval") : undefined,
|
||||
step_order: hasMixedStepTypes ? (a.step_order ?? idx + 1) : undefined,
|
||||
approver_label: hasMixedStepTypes
|
||||
? STEP_TYPE_LABEL[a.step_type || "approval"]?.label
|
||||
: approvalMode === "sequential"
|
||||
? `${idx + 1}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -251,198 +350,307 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 방식 */}
|
||||
{/* 결재 유형 + 템플릿 불러오기 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("sequential")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "sequential"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("parallel")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "parallel"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재자 추가 (사용자 검색) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
결재자 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs sm:text-sm">결재 유형</Label>
|
||||
<Select
|
||||
value={approvalType}
|
||||
onValueChange={(v) => setApprovalType(v as ApprovalType)}
|
||||
>
|
||||
<SelectTrigger size="default" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="escalation">상신결재</SelectItem>
|
||||
<SelectItem value="self">자기결재 (전결)</SelectItem>
|
||||
<SelectItem value="consensus">합의결재</SelectItem>
|
||||
<SelectItem value="post">후결</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{templates.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => setShowTemplatePopover(!showTemplatePopover)}
|
||||
>
|
||||
<FileText className="mr-1 h-3.5 w-3.5" />
|
||||
템플릿
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="이름 또는 사번으로 검색..."
|
||||
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
{/* 템플릿 선택 드롭다운 */}
|
||||
{showTemplatePopover && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowTemplatePopover(false)}
|
||||
/>
|
||||
<div className="relative z-50 mt-2 rounded-md border bg-popover p-2 shadow-lg">
|
||||
<p className="text-muted-foreground mb-2 text-[10px] font-medium">결재선 템플릿 선택</p>
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex items-center justify-center p-3">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-muted-foreground p-3 text-center text-xs">등록된 템플릿이 없습니다.</p>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
key={tpl.template_id}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
onClick={() => applyTemplate(tpl.template_id)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-xs transition-colors hover:bg-accent ${
|
||||
selectedTemplateId === tpl.template_id ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<FileText className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
<p className="truncate font-medium">{tpl.template_name}</p>
|
||||
{tpl.description && (
|
||||
<p className="text-muted-foreground truncate text-[10px]">{tpl.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 클릭 외부 영역 닫기 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{approvers.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||
위 검색창에서 결재자를 검색하여 추가하세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{approvers.map((approver, idx) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
{/* 순서 표시 */}
|
||||
{approvalMode === "sequential" ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "up")}
|
||||
disabled={idx === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "down")}
|
||||
disabled={idx === approvers.length - 1}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
동시
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{approver.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({approver.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제거 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(approver.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 */}
|
||||
{approvalMode === "sequential" && approvers.length > 1 && (
|
||||
<p className="text-muted-foreground text-center text-[10px]">
|
||||
{approvers.map((a) => a.user_name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 후결 안내 배너 */}
|
||||
{approvalType === "post" && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
||||
먼저 처리 후 나중에 결재받습니다. 결재 반려 시 별도 조치가 필요할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자기결재: 결재자 섹션 대신 안내 메시지 */}
|
||||
{approvalType === "self" ? (
|
||||
<div className="bg-muted text-muted-foreground rounded-md p-4 text-sm">
|
||||
본인이 직접 승인합니다.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 결재 방식 (escalation, post에서만 표시. consensus는 순서 무관) */}
|
||||
{approvalType !== "consensus" && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 방식</Label>
|
||||
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("sequential")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "sequential"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">다단 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">순차적으로 결재</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApprovalMode("parallel")}
|
||||
className={`flex items-center gap-2 rounded-md border p-3 text-left transition-colors ${
|
||||
approvalMode === "parallel"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Layers className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium sm:text-sm">동시 결재</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 결재자 동시 진행</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결재자 추가 (사용자 검색) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
{approvalType === "consensus" ? "합의 결재자" : "결재자"}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="이름 또는 사번으로 검색..."
|
||||
className="h-8 pl-9 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 클릭 외부 영역 닫기 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{approvers.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
|
||||
위 검색창에서 결재자를 검색하여 추가하세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{approvers.map((approver, idx) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className="bg-muted/30 flex items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
{/* 순서 표시: consensus에서는 drag 숨김 */}
|
||||
{approvalType === "consensus" ? (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
합의
|
||||
</Badge>
|
||||
) : approvalMode === "sequential" ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "up")}
|
||||
disabled={idx === 0}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
<Badge variant="outline" className="h-5 min-w-[24px] justify-center px-1 text-[10px]">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApprover(idx, "down")}
|
||||
disabled={idx === approvers.length - 1}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
|
||||
동시
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* step_type 뱃지 (템플릿에서 불러온 혼합형일 때) */}
|
||||
{approver.step_type && (
|
||||
<Badge
|
||||
variant={STEP_TYPE_LABEL[approver.step_type]?.variant || "outline"}
|
||||
className="h-5 shrink-0 px-1.5 text-[10px]"
|
||||
>
|
||||
{STEP_TYPE_LABEL[approver.step_type]?.label || approver.step_type}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{approver.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({approver.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[approver.dept_name, approver.position_name].filter(Boolean).join(" / ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제거 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(approver.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 (consensus가 아니고 sequential일 때만) */}
|
||||
{approvalType !== "consensus" && approvalMode === "sequential" && approvers.length > 1 && (
|
||||
<p className="text-muted-foreground text-center text-[10px]">
|
||||
{approvers.map((a) => a.user_name).join(" → ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 rounded-md p-2">
|
||||
|
|
@ -462,7 +670,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || approvers.length === 0}
|
||||
disabled={isSubmitting || (approvalType !== "self" && approvers.length === 0)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
@ -470,6 +678,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
요청 중...
|
||||
</>
|
||||
) : approvalType === "self" ? (
|
||||
"전결 처리"
|
||||
) : (
|
||||
`결재 상신 (${approvers.length}명)`
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ApprovalStepConfig } from "./types";
|
||||
import {
|
||||
ApprovalStepConfig,
|
||||
ExtendedApprovalLine,
|
||||
ExtendedApprovalRequest,
|
||||
StepGroup,
|
||||
} from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import {
|
||||
getApprovalRequests,
|
||||
getApprovalRequest,
|
||||
type ApprovalRequest,
|
||||
type ApprovalLine,
|
||||
} from "@/lib/api/approval";
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -20,14 +23,18 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
|
||||
|
||||
interface ApprovalStepData {
|
||||
request: ApprovalRequest;
|
||||
lines: ApprovalLine[];
|
||||
request: ExtendedApprovalRequest;
|
||||
lines: ExtendedApprovalLine[];
|
||||
approvalMode: "sequential" | "parallel";
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +94,75 @@ const REQUEST_STATUS_CONFIG = {
|
|||
cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" },
|
||||
} as const;
|
||||
|
||||
/** step_type에 대응하는 아이콘 */
|
||||
const STEP_TYPE_ICON = {
|
||||
approval: CheckCircle,
|
||||
consensus: Users,
|
||||
notification: Bell,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 결재 라인을 step_order 기준으로 그룹핑
|
||||
* 합의결재 시 같은 step_order에 여러 line이 존재할 수 있음
|
||||
*/
|
||||
function groupLinesByStepOrder(lines: ExtendedApprovalLine[]): StepGroup[] {
|
||||
const groupMap = new Map<number, ExtendedApprovalLine[]>();
|
||||
|
||||
for (const line of lines) {
|
||||
const order = line.step_order;
|
||||
if (!groupMap.has(order)) {
|
||||
groupMap.set(order, []);
|
||||
}
|
||||
groupMap.get(order)!.push(line);
|
||||
}
|
||||
|
||||
const groups: StepGroup[] = [];
|
||||
const sortedOrders = Array.from(groupMap.keys()).sort((a, b) => a - b);
|
||||
|
||||
for (const order of sortedOrders) {
|
||||
const groupLines = groupMap.get(order)!;
|
||||
const stepType = groupLines[0]?.step_type || "approval";
|
||||
groups.push({
|
||||
stepOrder: order,
|
||||
lines: groupLines,
|
||||
stepType,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 결재 라인에 표시할 뱃지 목록 반환 */
|
||||
function getLineBadges(
|
||||
line: ExtendedApprovalLine,
|
||||
request: ExtendedApprovalRequest,
|
||||
): Array<{ label: string; className: string }> {
|
||||
const badges: Array<{ label: string; className: string }> = [];
|
||||
|
||||
if (line.proxy_for) {
|
||||
badges.push({
|
||||
label: "대결",
|
||||
className: "border-orange-300 text-orange-600",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.approval_type === "post") {
|
||||
badges.push({
|
||||
label: "후결",
|
||||
className: "border-amber-300 text-amber-600",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.approval_type === "self") {
|
||||
badges.push({
|
||||
label: "전결",
|
||||
className: "border-blue-300 text-blue-600",
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 단계 시각화 컴포넌트
|
||||
* 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시
|
||||
|
|
@ -139,8 +215,8 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
const detailRes = await getApprovalRequest(latestRequest.request_id);
|
||||
|
||||
if (detailRes.success && detailRes.data) {
|
||||
const request = detailRes.data;
|
||||
const lines = request.lines || [];
|
||||
const request = detailRes.data as ExtendedApprovalRequest;
|
||||
const lines = (request.lines || []) as ExtendedApprovalLine[];
|
||||
const approvalMode =
|
||||
(request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential";
|
||||
|
||||
|
|
@ -160,7 +236,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
fetchApprovalData();
|
||||
}, [fetchApprovalData]);
|
||||
|
||||
// 디자인 모드용 샘플 데이터
|
||||
// 디자인 모드용 샘플 데이터 (합의/대결/통보 포함)
|
||||
useEffect(() => {
|
||||
if (isDesignMode) {
|
||||
setStepData({
|
||||
|
|
@ -171,13 +247,15 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
target_record_id: "1",
|
||||
status: "in_progress",
|
||||
current_step: 2,
|
||||
total_steps: 3,
|
||||
total_steps: 4,
|
||||
requester_id: "admin",
|
||||
requester_name: "홍길동",
|
||||
requester_dept: "개발팀",
|
||||
company_code: "SAMPLE",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
approval_type: "escalation",
|
||||
urgency: "urgent",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
|
|
@ -186,18 +264,37 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
status: "approved", comment: "확인했습니다.",
|
||||
processed_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
step_type: "approval",
|
||||
},
|
||||
{
|
||||
line_id: 2, request_id: 0, step_order: 2,
|
||||
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
|
||||
status: "pending",
|
||||
status: "approved",
|
||||
processed_at: new Date(Date.now() - 43200000).toISOString(),
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
step_type: "consensus",
|
||||
},
|
||||
{
|
||||
line_id: 3, request_id: 0, step_order: 3,
|
||||
approver_id: "user3", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
|
||||
line_id: 3, request_id: 0, step_order: 2,
|
||||
approver_id: "user3", approver_name: "최대리", approver_position: "대리", approver_dept: "기획팀",
|
||||
status: "pending",
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
step_type: "consensus",
|
||||
},
|
||||
{
|
||||
line_id: 4, request_id: 0, step_order: 3,
|
||||
approver_id: "user4", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
|
||||
status: "waiting",
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
step_type: "approval",
|
||||
proxy_for: "정팀장",
|
||||
},
|
||||
{
|
||||
line_id: 5, request_id: 0, step_order: 4,
|
||||
approver_id: "user5", approver_name: "한사원", approver_position: "사원", approver_dept: "총무팀",
|
||||
status: "waiting",
|
||||
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||
step_type: "notification",
|
||||
},
|
||||
],
|
||||
approvalMode: "sequential",
|
||||
|
|
@ -286,6 +383,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
|
||||
const { request, lines, approvalMode } = stepData;
|
||||
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
|
||||
const urgency = request.urgency;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||
|
|
@ -293,7 +391,10 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
{/* 헤더 - 요약 */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50",
|
||||
urgency === "critical" && "bg-destructive/10",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isDesignMode) setExpanded((prev) => !prev);
|
||||
|
|
@ -302,9 +403,26 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">{request.title}</span>
|
||||
{urgency === "urgent" && (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-orange-500" />
|
||||
)}
|
||||
{urgency === "critical" && (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
|
||||
{reqStatus.label}
|
||||
</span>
|
||||
{/* 결재 유형 뱃지 */}
|
||||
{request.approval_type === "post" && (
|
||||
<Badge variant="outline" className="h-4 border-amber-300 px-1.5 text-[9px] text-amber-600">
|
||||
후결
|
||||
</Badge>
|
||||
)}
|
||||
{request.approval_type === "self" && (
|
||||
<Badge variant="outline" className="h-4 border-blue-300 px-1.5 text-[9px] text-blue-600">
|
||||
전결
|
||||
</Badge>
|
||||
)}
|
||||
{approvalMode === "parallel" && (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
|
||||
동시결재
|
||||
|
|
@ -324,6 +442,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
{displayMode === "horizontal" ? (
|
||||
<HorizontalStepper
|
||||
lines={lines}
|
||||
request={request}
|
||||
approvalMode={approvalMode}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
|
|
@ -331,6 +450,7 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
) : (
|
||||
<VerticalStepper
|
||||
lines={lines}
|
||||
request={request}
|
||||
approvalMode={approvalMode}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
|
|
@ -349,16 +469,35 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
<span>상신자: {request.requester_name || request.requester_id}</span>
|
||||
{request.requester_dept && <span>부서: {request.requester_dept}</span>}
|
||||
<span>상신일: {formatDate(request.created_at)}</span>
|
||||
{request.approval_type && request.approval_type !== "escalation" && (
|
||||
<span>
|
||||
유형: {request.approval_type === "self" ? "전결" : request.approval_type === "post" ? "후결" : request.approval_type === "consensus" ? "합의" : request.approval_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{displayMode === "horizontal" && lines.length > 0 && (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{lines.map((line) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const lineBadges = getLineBadges(line, request);
|
||||
const StepIcon = STEP_TYPE_ICON[line.step_type || "approval"];
|
||||
return (
|
||||
<div key={line.line_id} className="flex items-start gap-2 text-[11px]">
|
||||
<span className={cn("mt-0.5 inline-block h-2 w-2 shrink-0 rounded-full", sc.dotColor)} />
|
||||
<StepIcon className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{line.approver_name || line.approver_id}</span>
|
||||
{lineBadges.map((b) => (
|
||||
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
|
||||
{line.step_type === "notification" && (
|
||||
<span className="text-muted-foreground">(자동 통보)</span>
|
||||
)}
|
||||
{line.proxy_for && (
|
||||
<span className="text-orange-600">({line.proxy_for} 대결)</span>
|
||||
)}
|
||||
{showTimestamp && line.processed_at && (
|
||||
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
|
||||
)}
|
||||
|
|
@ -378,9 +517,10 @@ export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
/* ========== 가로형 스테퍼 ========== */
|
||||
/* ========== 공통 Props ========== */
|
||||
interface StepperProps {
|
||||
lines: ApprovalLine[];
|
||||
lines: ExtendedApprovalLine[];
|
||||
request: ExtendedApprovalRequest;
|
||||
approvalMode: "sequential" | "parallel";
|
||||
compact: boolean;
|
||||
showDept: boolean;
|
||||
|
|
@ -389,43 +529,124 @@ interface StepperProps {
|
|||
formatDate?: (d?: string | null) => string;
|
||||
}
|
||||
|
||||
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
|
||||
/* ========== 결재자 카드 (가로형/세로형 공용) ========== */
|
||||
interface ApproverCardProps {
|
||||
line: ExtendedApprovalLine;
|
||||
request: ExtendedApprovalRequest;
|
||||
compact: boolean;
|
||||
showDept: boolean;
|
||||
variant: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
const ApproverCard: React.FC<ApproverCardProps> = ({ line, request, compact, showDept, variant }) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const lineBadges = getLineBadges(line, request);
|
||||
const isNotification = line.step_type === "notification";
|
||||
|
||||
if (variant === "horizontal") {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-0.5",
|
||||
isNotification && "rounded px-1 py-0.5 bg-muted/50",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-2 transition-all",
|
||||
sc.bgColor,
|
||||
sc.borderColor,
|
||||
compact ? "h-6 w-6" : "h-8 w-8"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{lineBadges.map((b) => (
|
||||
<Badge key={b.label} variant="outline" className={cn("h-3 px-0.5 text-[7px] leading-none", b.className)}>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{showDept && !compact && (line.approver_position || line.approver_dept) && (
|
||||
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
|
||||
{line.approver_position || line.approver_dept}
|
||||
</span>
|
||||
)}
|
||||
{isNotification && !compact && (
|
||||
<span className="text-[8px] text-muted-foreground">(자동 통보)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/* ========== 가로형 스테퍼 ========== */
|
||||
const HorizontalStepper: React.FC<StepperProps> = ({ lines, request, approvalMode, compact, showDept }) => {
|
||||
const stepGroups = useMemo(() => groupLinesByStepOrder(lines), [lines]);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 overflow-x-auto">
|
||||
{lines.map((line, idx) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const isLast = idx === lines.length - 1;
|
||||
{stepGroups.map((group, groupIdx) => {
|
||||
const StepTypeIcon = STEP_TYPE_ICON[group.stepType];
|
||||
const isLast = groupIdx === stepGroups.length - 1;
|
||||
const isConsensus = group.lines.length > 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={line.line_id}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
{/* 아이콘 원 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-2 transition-all",
|
||||
sc.bgColor,
|
||||
sc.borderColor,
|
||||
compact ? "h-6 w-6" : "h-8 w-8"
|
||||
<React.Fragment key={group.stepOrder}>
|
||||
{isConsensus ? (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
{/* 합의결재: step_type 아이콘 표시 */}
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-0.5 text-[8px] text-muted-foreground">
|
||||
<StepTypeIcon className="h-3 w-3" />
|
||||
<span>합의</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
|
||||
{/* 같은 step_order의 결재자들을 가로로 나열 */}
|
||||
<div className="flex items-start gap-2 rounded-md border border-dashed border-muted-foreground/30 px-1.5 py-1">
|
||||
{group.lines.map((line, lineIdx) => (
|
||||
<React.Fragment key={line.line_id}>
|
||||
<ApproverCard
|
||||
line={line}
|
||||
request={request}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
variant="horizontal"
|
||||
/>
|
||||
{lineIdx < group.lines.length - 1 && (
|
||||
<div className="flex items-center self-center text-[9px] text-muted-foreground/50">+</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 결재자 이름 */}
|
||||
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{/* 직급/부서 */}
|
||||
{showDept && !compact && (line.approver_position || line.approver_dept) && (
|
||||
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
|
||||
{line.approver_position || line.approver_dept}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||
{/* 단일 결재자: step_type 아이콘 (통보/합의만 표시) */}
|
||||
{!compact && group.stepType !== "approval" && (
|
||||
<div className="flex items-center gap-0.5 text-[8px] text-muted-foreground">
|
||||
<StepTypeIcon className="h-3 w-3" />
|
||||
<span>{group.stepType === "notification" ? "통보" : "합의"}</span>
|
||||
</div>
|
||||
)}
|
||||
<ApproverCard
|
||||
line={group.lines[0]}
|
||||
request={request}
|
||||
compact={compact}
|
||||
showDept={showDept}
|
||||
variant="horizontal"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연결선 */}
|
||||
{!isLast && (
|
||||
|
|
@ -447,6 +668,7 @@ const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compac
|
|||
/* ========== 세로형 스테퍼 ========== */
|
||||
const VerticalStepper: React.FC<StepperProps> = ({
|
||||
lines,
|
||||
request,
|
||||
approvalMode,
|
||||
compact,
|
||||
showDept,
|
||||
|
|
@ -454,30 +676,29 @@ const VerticalStepper: React.FC<StepperProps> = ({
|
|||
showTimestamp,
|
||||
formatDate,
|
||||
}) => {
|
||||
const stepGroups = useMemo(() => groupLinesByStepOrder(lines), [lines]);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{lines.map((line, idx) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const isLast = idx === lines.length - 1;
|
||||
{stepGroups.map((group, groupIdx) => {
|
||||
const StepTypeIcon = STEP_TYPE_ICON[group.stepType];
|
||||
const isLast = groupIdx === stepGroups.length - 1;
|
||||
const isConsensus = group.lines.length > 1;
|
||||
const isNotificationGroup = group.stepType === "notification";
|
||||
|
||||
return (
|
||||
<div key={line.line_id} className="flex gap-3">
|
||||
<div key={group.stepOrder} className="flex gap-3">
|
||||
{/* 타임라인 바 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border-2",
|
||||
sc.bgColor,
|
||||
sc.borderColor,
|
||||
compact ? "h-5 w-5" : "h-7 w-7"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3.5 w-3.5")} />
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<StepTypeIcon className={cn(
|
||||
"text-muted-foreground",
|
||||
compact ? "h-3.5 w-3.5" : "h-4 w-4",
|
||||
)} />
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div
|
||||
|
|
@ -493,29 +714,112 @@ const VerticalStepper: React.FC<StepperProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 결재자 정보 */}
|
||||
<div className={cn("pb-2", compact ? "pb-1" : "pb-3")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{showDept && (line.approver_position || line.approver_dept) && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</div>
|
||||
{showTimestamp && line.processed_at && formatDate && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{formatDate(line.processed_at)}
|
||||
</div>
|
||||
)}
|
||||
{showComment && line.comment && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
"{line.comment}"
|
||||
<div className={cn(
|
||||
"flex-1",
|
||||
compact ? "pb-1" : "pb-3",
|
||||
isNotificationGroup && "rounded bg-muted/50 px-2 py-1",
|
||||
)}>
|
||||
{isConsensus ? (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
<span>합의결재 ({group.lines.length}명)</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{group.lines.map((line) => {
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const lineBadges = getLineBadges(line, request);
|
||||
return (
|
||||
<div key={line.line_id} className="flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1">
|
||||
<div className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border",
|
||||
sc.bgColor, sc.borderColor,
|
||||
"h-5 w-5",
|
||||
)}>
|
||||
<StatusIcon className={cn(sc.iconColor, "h-2.5 w-2.5")} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{lineBadges.map((b) => (
|
||||
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
<span className={cn("text-[9px] font-medium", sc.textColor)}>{sc.label}</span>
|
||||
</div>
|
||||
{showDept && (line.approver_position || line.approver_dept) && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
{showTimestamp && line.processed_at && formatDate && (
|
||||
<div className="text-[9px] text-muted-foreground">{formatDate(line.processed_at)}</div>
|
||||
)}
|
||||
{showComment && line.comment && (
|
||||
<div className="text-[9px] text-muted-foreground">"{line.comment}"</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const line = group.lines[0];
|
||||
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||
const StatusIcon = sc.icon;
|
||||
const lineBadges = getLineBadges(line, request);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full border-2",
|
||||
sc.bgColor, sc.borderColor,
|
||||
compact ? "h-5 w-5" : "h-6 w-6",
|
||||
)}>
|
||||
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3 w-3")} />
|
||||
</div>
|
||||
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
||||
{line.approver_name || line.approver_id}
|
||||
</span>
|
||||
{lineBadges.map((b) => (
|
||||
<Badge key={b.label} variant="outline" className={cn("h-3.5 px-1 text-[8px]", b.className)}>
|
||||
{b.label}
|
||||
</Badge>
|
||||
))}
|
||||
{showDept && (line.approver_position || line.approver_dept) && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
|
||||
{sc.label}
|
||||
</span>
|
||||
{isNotificationGroup && (
|
||||
<span className="text-[9px] text-muted-foreground">(자동 통보)</span>
|
||||
)}
|
||||
{line.proxy_for && (
|
||||
<span className="text-[9px] text-orange-600">({line.proxy_for} 대결)</span>
|
||||
)}
|
||||
</div>
|
||||
{showTimestamp && line.processed_at && formatDate && (
|
||||
<div className="mt-0.5 pl-8 text-[10px] text-muted-foreground">
|
||||
{formatDate(line.processed_at)}
|
||||
</div>
|
||||
)}
|
||||
{showComment && line.comment && (
|
||||
<div className="mt-0.5 pl-8 text-[10px] text-muted-foreground">
|
||||
"{line.comment}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
import type { ApprovalLine, ApprovalRequest } from "@/lib/api/approval";
|
||||
|
||||
/**
|
||||
* ApprovalStep 컴포넌트 설정 타입
|
||||
|
|
@ -15,3 +16,34 @@ export interface ApprovalStepConfig extends ComponentConfig {
|
|||
showDept?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 라인 확장 타입 (v2 마이그레이션 필드 포함)
|
||||
* DB에 step_type, proxy_for, proxy_reason, is_required 컬럼 추가됨
|
||||
*/
|
||||
export interface ExtendedApprovalLine extends ApprovalLine {
|
||||
step_type?: "approval" | "consensus" | "notification";
|
||||
proxy_for?: string | null;
|
||||
proxy_reason?: string | null;
|
||||
is_required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 확장 타입 (v2 마이그레이션 필드 포함)
|
||||
* DB에 approval_type, is_post_approved, urgency 컬럼 추가됨
|
||||
*/
|
||||
export interface ExtendedApprovalRequest extends ApprovalRequest {
|
||||
approval_type?: "self" | "escalation" | "consensus" | "post";
|
||||
is_post_approved?: boolean;
|
||||
urgency?: "normal" | "urgent" | "critical";
|
||||
}
|
||||
|
||||
/**
|
||||
* step_order 기준으로 그룹핑된 결재 단계
|
||||
* 합의결재 시 같은 step_order에 여러 결재자가 존재
|
||||
*/
|
||||
export interface StepGroup {
|
||||
stepOrder: number;
|
||||
lines: ExtendedApprovalLine[];
|
||||
stepType: "approval" | "consensus" | "notification";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue