ERP-node/frontend/components/approval/ApprovalRequestModal.tsx

694 lines
27 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
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 {
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,
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 {
targetTable: string;
targetRecordId: string;
targetRecordData?: Record<string, any>;
definitionId?: number;
screenId?: number;
buttonComponentId?: string;
}
interface ApprovalRequestModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
eventDetail?: ApprovalModalEventDetail | null;
}
interface UserSearchResult {
userId: string;
userName: string;
positionName?: string;
deptName?: string;
deptCode?: string;
email?: string;
user_id?: string;
user_name?: string;
position_name?: string;
dept_name?: string;
}
function genId(): string {
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
open,
onOpenChange,
eventDetail,
}) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
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("");
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
// 모달 닫힐 때 초기화
useEffect(() => {
if (!open) {
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) {
setSearchResults([]);
return;
}
setIsSearching(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 || "",
deptCode: u.deptCode || u.dept_code || "",
email: u.email || "",
}));
const existingIds = new Set(approvers.map((a) => a.user_id));
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, [approvers]);
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(() => {
searchUsers(searchQuery);
}, 300);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [searchQuery, searchUsers]);
const addApprover = (user: UserSearchResult) => {
setApprovers((prev) => [
...prev,
{
id: genId(),
user_id: user.userId,
user_name: user.userName,
position_name: user.positionName || "",
dept_name: user.deptName || "",
},
]);
setSearchQuery("");
setSearchResults([]);
setSearchOpen(false);
};
const removeApprover = (id: string) => {
setApprovers((prev) => prev.filter((a) => a.id !== id));
};
const moveApprover = (idx: number, direction: "up" | "down") => {
setApprovers((prev) => {
const next = [...prev];
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= next.length) return prev;
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
return next;
});
};
const handleSubmit = async () => {
if (!title.trim()) {
setError("결재 제목을 입력해주세요.");
return;
}
// 자기결재가 아닌 경우 결재자 필수
if (approvalType !== "self" && approvers.length === 0) {
setError("결재자를 1명 이상 추가해주세요.");
return;
}
if (!eventDetail?.targetTable) {
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
return;
}
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: approvalType === "consensus" ? "parallel" : approvalMode,
approval_type: approvalType,
screen_id: eventDetail.screenId,
button_component_id: eventDetail.buttonComponentId,
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);
if (res.success) {
toast.success("결재 요청이 완료되었습니다.");
onOpenChange(false);
} else {
setError(res.error || res.message || "결재 요청에 실패했습니다.");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
{/* 결재 제목 */}
<div>
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="approval-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="결재 제목을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 결재 사유 */}
<div>
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
</Label>
<Textarea
id="approval-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="결재 사유를 입력하세요 (선택사항)"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
{/* 결재 유형 + 템플릿 불러오기 */}
<div>
<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>
{/* 템플릿 선택 드롭다운 */}
{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" />
</div>
) : templates.length === 0 ? (
<p className="text-muted-foreground p-3 text-center text-xs"> 릿 .</p>
) : (
<div className="max-h-40 space-y-1 overflow-y-auto">
{templates.map((tpl) => (
<button
key={tpl.template_id}
type="button"
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" : ""
}`}
>
<FileText className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{tpl.template_name}</p>
{tpl.description && (
<p className="text-muted-foreground truncate text-[10px]">{tpl.description}</p>
)}
</div>
</button>
))}
</div>
)}
</div>
</>
)}
</div>
{/* 후결 안내 배너 */}
{approvalType === "post" && (
<div className="rounded-md border border-warning/30 bg-warning/10 p-3 text-sm text-warning">
. .
</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">
<p className="text-destructive text-xs">{error}</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (approvalType !== "self" && approvers.length === 0)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : approvalType === "self" ? (
"전결 처리"
) : (
`결재 상신 (${approvers.length}명)`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApprovalRequestModal;