2026-03-03 22:00:52 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
2026-03-03 22:00:52 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
2026-03-04 11:19:57 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { createApprovalRequest } from "@/lib/api/approval";
|
|
|
|
|
import { getUserList } from "@/lib/api/user";
|
|
|
|
|
|
|
|
|
|
// 결재 방식
|
|
|
|
|
type ApprovalMode = "sequential" | "parallel";
|
|
|
|
|
|
2026-03-03 22:00:52 +09:00
|
|
|
interface ApproverRow {
|
2026-03-04 11:19:57 +09:00
|
|
|
id: string;
|
|
|
|
|
user_id: string;
|
|
|
|
|
user_name: string;
|
|
|
|
|
position_name: string;
|
|
|
|
|
dept_name: string;
|
2026-03-03 22:00:52 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
interface UserSearchResult {
|
2026-03-04 18:26:16 +09:00
|
|
|
userId: string;
|
|
|
|
|
userName: string;
|
|
|
|
|
positionName?: string;
|
|
|
|
|
deptName?: string;
|
|
|
|
|
deptCode?: string;
|
|
|
|
|
email?: string;
|
|
|
|
|
user_id?: string;
|
|
|
|
|
user_name?: string;
|
2026-03-04 11:19:57 +09:00
|
|
|
position_name?: string;
|
|
|
|
|
dept_name?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function genId(): string {
|
|
|
|
|
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
2026-03-03 22:00:52 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
eventDetail,
|
|
|
|
|
}) => {
|
|
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [description, setDescription] = useState("");
|
2026-03-04 11:19:57 +09:00
|
|
|
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
|
2026-03-03 22:00:52 +09:00
|
|
|
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
// 사용자 검색 상태
|
|
|
|
|
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);
|
2026-03-03 22:00:52 +09:00
|
|
|
|
|
|
|
|
// 모달 닫힐 때 초기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setTitle("");
|
|
|
|
|
setDescription("");
|
2026-03-04 11:19:57 +09:00
|
|
|
setApprovalMode("sequential");
|
2026-03-03 22:00:52 +09:00
|
|
|
setApprovers([]);
|
|
|
|
|
setError(null);
|
2026-03-04 11:19:57 +09:00
|
|
|
setSearchOpen(false);
|
|
|
|
|
setSearchQuery("");
|
|
|
|
|
setSearchResults([]);
|
2026-03-03 22:00:52 +09:00
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
// 사용자 검색 (디바운스)
|
|
|
|
|
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 || [];
|
2026-03-04 18:26:16 +09:00
|
|
|
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 || "",
|
|
|
|
|
}));
|
2026-03-04 11:19:57 +09:00
|
|
|
const existingIds = new Set(approvers.map((a) => a.user_id));
|
2026-03-04 18:26:16 +09:00
|
|
|
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
|
2026-03-04 11:19:57 +09:00
|
|
|
} 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) => {
|
2026-03-03 22:00:52 +09:00
|
|
|
setApprovers((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
{
|
2026-03-04 11:19:57 +09:00
|
|
|
id: genId(),
|
2026-03-04 18:26:16 +09:00
|
|
|
user_id: user.userId,
|
|
|
|
|
user_name: user.userName,
|
|
|
|
|
position_name: user.positionName || "",
|
|
|
|
|
dept_name: user.deptName || "",
|
2026-03-03 22:00:52 +09:00
|
|
|
},
|
|
|
|
|
]);
|
2026-03-04 11:19:57 +09:00
|
|
|
setSearchQuery("");
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
setSearchOpen(false);
|
2026-03-03 22:00:52 +09:00
|
|
|
};
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
const removeApprover = (id: string) => {
|
2026-03-03 22:00:52 +09:00
|
|
|
setApprovers((prev) => prev.filter((a) => a.id !== id));
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
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;
|
|
|
|
|
});
|
2026-03-03 22:00:52 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
if (!title.trim()) {
|
|
|
|
|
setError("결재 제목을 입력해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (approvers.length === 0) {
|
|
|
|
|
setError("결재자를 1명 이상 추가해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-04 18:26:16 +09:00
|
|
|
if (!eventDetail?.targetTable) {
|
|
|
|
|
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
2026-03-03 22:00:52 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
const res = await createApprovalRequest({
|
|
|
|
|
title: title.trim(),
|
|
|
|
|
description: description.trim() || undefined,
|
|
|
|
|
target_table: eventDetail.targetTable,
|
2026-03-04 18:26:16 +09:00
|
|
|
target_record_id: eventDetail.targetRecordId || undefined,
|
|
|
|
|
target_record_data: eventDetail.targetRecordData,
|
|
|
|
|
approval_mode: approvalMode,
|
2026-03-03 22:00:52 +09:00
|
|
|
screen_id: eventDetail.screenId,
|
|
|
|
|
button_component_id: eventDetail.buttonComponentId,
|
2026-03-04 11:19:57 +09:00
|
|
|
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}차 결재`
|
|
|
|
|
: "동시 결재",
|
2026-03-03 22:00:52 +09:00
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
|
|
|
|
|
if (res.success) {
|
2026-03-04 11:19:57 +09:00
|
|
|
toast.success("결재 요청이 완료되었습니다.");
|
2026-03-03 22:00:52 +09:00
|
|
|
onOpenChange(false);
|
|
|
|
|
} else {
|
|
|
|
|
setError(res.error || res.message || "결재 요청에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
|
|
|
|
<DialogHeader>
|
2026-03-04 11:19:57 +09:00
|
|
|
<DialogTitle className="text-base sm:text-lg">결재 상신</DialogTitle>
|
2026-03-03 22:00:52 +09:00
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
2026-03-04 11:19:57 +09:00
|
|
|
결재 방식을 선택하고 결재자를 검색하여 추가합니다.
|
2026-03-03 22:00:52 +09:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
|
2026-03-03 22:00:52 +09:00
|
|
|
{/* 결재 제목 */}
|
|
|
|
|
<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>
|
2026-03-04 11:19:57 +09:00
|
|
|
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
|
2026-03-03 22:00:52 +09:00
|
|
|
결재 사유
|
|
|
|
|
</Label>
|
|
|
|
|
<Textarea
|
2026-03-04 11:19:57 +09:00
|
|
|
id="approval-desc"
|
2026-03-03 22:00:52 +09:00
|
|
|
value={description}
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
placeholder="결재 사유를 입력하세요 (선택사항)"
|
|
|
|
|
className="min-h-[60px] text-xs sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
{/* 결재 방식 */}
|
2026-03-03 22:00:52 +09:00
|
|
|
<div>
|
2026-03-04 11:19:57 +09:00
|
|
|
<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"
|
|
|
|
|
}`}
|
2026-03-03 22:00:52 +09:00
|
|
|
>
|
2026-03-04 11:19:57 +09:00
|
|
|
<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>
|
2026-03-03 22:00:52 +09:00
|
|
|
</div>
|
2026-03-04 11:19:57 +09:00
|
|
|
</div>
|
2026-03-03 22:00:52 +09:00
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
{/* 결재자 추가 (사용자 검색) */}
|
2026-03-03 22:00:52 +09:00
|
|
|
<div>
|
|
|
|
|
<div className="mb-2 flex items-center justify-between">
|
|
|
|
|
<Label className="text-xs sm:text-sm">
|
2026-03-04 11:19:57 +09:00
|
|
|
결재자 <span className="text-destructive">*</span>
|
2026-03-03 22:00:52 +09:00
|
|
|
</Label>
|
2026-03-04 11:19:57 +09:00
|
|
|
<span className="text-muted-foreground text-[10px]">
|
|
|
|
|
{approvers.length}명 선택됨
|
|
|
|
|
</span>
|
2026-03-03 22:00:52 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
{/* 검색 입력 */}
|
|
|
|
|
<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
|
2026-03-04 18:26:16 +09:00
|
|
|
key={user.userId}
|
2026-03-04 11:19:57 +09:00
|
|
|
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">
|
2026-03-04 18:26:16 +09:00
|
|
|
{user.userName}
|
2026-03-04 11:19:57 +09:00
|
|
|
<span className="text-muted-foreground ml-1 text-[10px]">
|
2026-03-04 18:26:16 +09:00
|
|
|
({user.userId})
|
2026-03-04 11:19:57 +09:00
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-muted-foreground truncate text-[10px]">
|
2026-03-04 18:26:16 +09:00
|
|
|
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
2026-03-04 11:19:57 +09:00
|
|
|
</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)}
|
|
|
|
|
/>
|
2026-03-03 22:00:52 +09:00
|
|
|
)}
|
|
|
|
|
|
2026-03-04 11:19:57 +09:00
|
|
|
{/* 선택된 결재자 목록 */}
|
|
|
|
|
{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>
|
|
|
|
|
|
|
|
|
|
{/* 제거 버튼 */}
|
2026-03-03 22:00:52 +09:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
2026-03-04 11:19:57 +09:00
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 shrink-0"
|
|
|
|
|
onClick={() => removeApprover(approver.id)}
|
2026-03-03 22:00:52 +09:00
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-03-04 11:19:57 +09:00
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* 결재 흐름 시각화 */}
|
|
|
|
|
{approvalMode === "sequential" && approvers.length > 1 && (
|
|
|
|
|
<p className="text-muted-foreground text-center text-[10px]">
|
|
|
|
|
{approvers.map((a) => a.user_name).join(" → ")}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-03 22:00:52 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 에러 메시지 */}
|
|
|
|
|
{error && (
|
2026-03-04 11:19:57 +09:00
|
|
|
<div className="bg-destructive/10 rounded-md p-2">
|
|
|
|
|
<p className="text-destructive text-xs">{error}</p>
|
|
|
|
|
</div>
|
2026-03-03 22:00:52 +09:00
|
|
|
)}
|
|
|
|
|
</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}
|
2026-03-04 11:19:57 +09:00
|
|
|
disabled={isSubmitting || approvers.length === 0}
|
2026-03-03 22:00:52 +09:00
|
|
|
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" />
|
|
|
|
|
요청 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
2026-03-04 11:19:57 +09:00
|
|
|
`결재 상신 (${approvers.length}명)`
|
2026-03-03 22:00:52 +09:00
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ApprovalRequestModal;
|