feature/v2-renewal #400
|
|
@ -0,0 +1,426 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getApprovalRequests,
|
||||||
|
getApprovalRequest,
|
||||||
|
getMyPendingApprovals,
|
||||||
|
processApprovalLine,
|
||||||
|
cancelApprovalRequest,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type ApprovalLine,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
// 상태 배지 색상
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
requested: { label: "요청됨", variant: "secondary" },
|
||||||
|
in_progress: { label: "진행 중", variant: "default" },
|
||||||
|
approved: { label: "승인됨", variant: "outline" },
|
||||||
|
rejected: { label: "반려됨", variant: "destructive" },
|
||||||
|
cancelled: { label: "취소됨", variant: "secondary" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
|
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
|
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
||||||
|
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-green-600" /> },
|
||||||
|
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||||
|
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 결재 상세 모달
|
||||||
|
interface ApprovalDetailModalProps {
|
||||||
|
request: ApprovalRequest | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
pendingLineId?: number; // 내가 처리해야 할 결재 라인 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId }: ApprovalDetailModalProps) {
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setComment("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleProcess = async (action: "approved" | "rejected") => {
|
||||||
|
if (!pendingLineId) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
|
||||||
|
setIsProcessing(false);
|
||||||
|
if (res.success) {
|
||||||
|
onRefresh();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!request) return;
|
||||||
|
setIsCancelling(true);
|
||||||
|
const res = await cancelApprovalRequest(request.request_id);
|
||||||
|
setIsCancelling(false);
|
||||||
|
if (res.success) {
|
||||||
|
onRefresh();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!request) return null;
|
||||||
|
|
||||||
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<FileCheck2 className="h-5 w-5" />
|
||||||
|
{request.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
<Badge variant={statusInfo.variant} className="mr-2">
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
요청자: {request.requester_name || request.requester_id}
|
||||||
|
{request.requester_dept ? ` (${request.requester_dept})` : ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 결재 사유 */}
|
||||||
|
{request.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 사유</p>
|
||||||
|
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm">{request.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결재선 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs font-medium">결재선</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(request.lines || []).map((line) => {
|
||||||
|
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line.line_id}
|
||||||
|
className="flex items-start justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lineStatus.icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium sm:text-sm">
|
||||||
|
{line.approver_label || `${line.step_order}차 결재`} — {line.approver_name || line.approver_id}
|
||||||
|
</p>
|
||||||
|
{line.approver_position && (
|
||||||
|
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
|
||||||
|
)}
|
||||||
|
{line.comment && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
의견: {line.comment}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 승인/반려 입력 (대기 상태일 때만) */}
|
||||||
|
{pendingLineId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium">결재 의견 (선택사항)</p>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="결재 의견을 입력하세요"
|
||||||
|
className="min-h-[60px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
|
||||||
|
{/* 요청자만 취소 가능 (요청됨/진행 중 상태) */}
|
||||||
|
{(request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isCancelling}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
|
||||||
|
회수
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pendingLineId && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleProcess("rejected")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleProcess("approved")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 대기 행 (ApprovalLine 기반)
|
||||||
|
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
|
||||||
|
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||||
|
const createdAt = line.request_created_at || line.created_at;
|
||||||
|
const formattedDate = createdAt
|
||||||
|
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
||||||
|
{line.requester_name && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
요청자: {line.requester_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] sm:text-xs">
|
||||||
|
{statusInfo.icon}
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결재 요청 행 (ApprovalRequest 기반)
|
||||||
|
function ApprovalRequestRow({ request, onClick }: { request: ApprovalRequest; onClick: () => void }) {
|
||||||
|
const statusInfo = statusConfig[request.status] || { label: request.status, variant: "secondary" as const };
|
||||||
|
const formattedDate = request.created_at
|
||||||
|
? new Date(request.created_at).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">{request.title}</p>
|
||||||
|
{request.requester_name && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
요청자: {request.requester_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<Badge variant={statusInfo.variant} className="text-[10px]">
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 상태 컴포넌트
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||||
|
<FileCheck2 className="h-7 w-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 결재함 페이지
|
||||||
|
export default function ApprovalPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("pending");
|
||||||
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||||
|
const [myRequests, setMyRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [completedRequests, setCompletedRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 상세 모달
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||||
|
const [selectedPendingLineId, setSelectedPendingLineId] = useState<number | undefined>();
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const [pendingRes, myRes, completedRes] = await Promise.all([
|
||||||
|
getMyPendingApprovals(),
|
||||||
|
// my_approvals 없이 호출 → 백엔드에서 현재 사용자의 요청 건 반환
|
||||||
|
getApprovalRequests(),
|
||||||
|
getApprovalRequests({ status: "approved" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pendingRes.success && pendingRes.data) setPendingLines(pendingRes.data);
|
||||||
|
if (myRes.success && myRes.data) setMyRequests(myRes.data);
|
||||||
|
if (completedRes.success && completedRes.data) setCompletedRequests(completedRes.data);
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleOpenDetail = async (requestId: number, pendingLineId?: number) => {
|
||||||
|
const res = await getApprovalRequest(requestId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setSelectedRequest(res.data);
|
||||||
|
setSelectedPendingLineId(pendingLineId);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenFromLine = async (line: ApprovalLine) => {
|
||||||
|
if (!line.request_id) return;
|
||||||
|
await handleOpenDetail(line.request_id, line.line_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-3xl p-4 sm:p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold sm:text-2xl">결재함</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">결재 요청을 확인하고 처리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-4 grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="pending" className="text-xs sm:text-sm">
|
||||||
|
대기함
|
||||||
|
{pendingLines.length > 0 && (
|
||||||
|
<Badge variant="destructive" className="ml-1 h-4 min-w-[16px] px-1 text-[10px]">
|
||||||
|
{pendingLines.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="my-requests" className="text-xs sm:text-sm">
|
||||||
|
요청함
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed" className="text-xs sm:text-sm">
|
||||||
|
완료함
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 대기함: 내가 결재해야 할 건 */}
|
||||||
|
<TabsContent value="pending">
|
||||||
|
{pendingLines.length === 0 ? (
|
||||||
|
<EmptyState message="결재 대기 중인 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingLines.map((line) => (
|
||||||
|
<ApprovalLineRow
|
||||||
|
key={line.line_id}
|
||||||
|
line={line}
|
||||||
|
onClick={() => handleOpenFromLine(line)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 요청함: 내가 요청한 건 */}
|
||||||
|
<TabsContent value="my-requests">
|
||||||
|
{myRequests.length === 0 ? (
|
||||||
|
<EmptyState message="요청한 결재 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{myRequests.map((req) => (
|
||||||
|
<ApprovalRequestRow
|
||||||
|
key={req.request_id}
|
||||||
|
request={req}
|
||||||
|
onClick={() => handleOpenDetail(req.request_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 완료함 */}
|
||||||
|
<TabsContent value="completed">
|
||||||
|
{completedRequests.length === 0 ? (
|
||||||
|
<EmptyState message="완료된 결재 건이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<ApprovalRequestRow
|
||||||
|
key={req.request_id}
|
||||||
|
request={req}
|
||||||
|
onClick={() => handleOpenDetail(req.request_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 결재 상세 모달 */}
|
||||||
|
<ApprovalDetailModal
|
||||||
|
request={selectedRequest}
|
||||||
|
open={detailModalOpen}
|
||||||
|
onClose={() => setDetailModalOpen(false)}
|
||||||
|
onRefresh={loadData}
|
||||||
|
pendingLineId={selectedPendingLineId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { AuthProvider } from "@/contexts/AuthContext";
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
import { MenuProvider } from "@/contexts/MenuContext";
|
import { MenuProvider } from "@/contexts/MenuContext";
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
|
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MenuProvider>
|
<MenuProvider>
|
||||||
<AppLayout>{children}</AppLayout>
|
<AppLayout>{children}</AppLayout>
|
||||||
|
<ApprovalGlobalListener />
|
||||||
</MenuProvider>
|
</MenuProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { ApprovalRequestModal, type ApprovalModalEventDetail } from "./ApprovalRequestModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 결재 요청 모달 리스너
|
||||||
|
*
|
||||||
|
* CustomEvent('open-approval-modal')를 수신하여 ApprovalRequestModal을 엽니다.
|
||||||
|
*
|
||||||
|
* 이벤트 발송 예시:
|
||||||
|
* window.dispatchEvent(new CustomEvent('open-approval-modal', {
|
||||||
|
* detail: {
|
||||||
|
* targetTable: 'purchase_orders',
|
||||||
|
* targetRecordId: '123',
|
||||||
|
* targetRecordData: { ... },
|
||||||
|
* definitionId: 1,
|
||||||
|
* screenId: 10,
|
||||||
|
* buttonComponentId: 'btn-approval-001',
|
||||||
|
* }
|
||||||
|
* }));
|
||||||
|
*/
|
||||||
|
export const ApprovalGlobalListener: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [eventDetail, setEventDetail] = useState<ApprovalModalEventDetail | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenModal = (e: Event) => {
|
||||||
|
const customEvent = e as CustomEvent<ApprovalModalEventDetail>;
|
||||||
|
setEventDetail(customEvent.detail || null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("open-approval-modal", handleOpenModal);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("open-approval-modal", handleOpenModal);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApprovalRequestModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) setEventDetail(null);
|
||||||
|
}}
|
||||||
|
eventDetail={eventDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalGlobalListener;
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Plus, X, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getApprovalDefinitions,
|
||||||
|
getApprovalTemplates,
|
||||||
|
createApprovalRequest,
|
||||||
|
type ApprovalDefinition,
|
||||||
|
type ApprovalLineTemplate,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
// 결재자 행 타입
|
||||||
|
interface ApproverRow {
|
||||||
|
id: string; // 로컬 임시 ID
|
||||||
|
approver_id: string;
|
||||||
|
approver_name: string;
|
||||||
|
approver_position: string;
|
||||||
|
approver_dept: string;
|
||||||
|
approver_label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 열기 이벤트로 전달되는 데이터
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateLocalId(): string {
|
||||||
|
return `row_${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 [selectedDefinitionId, setSelectedDefinitionId] = useState<string>("");
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
|
const [approvers, setApprovers] = useState<ApproverRow[]>([]);
|
||||||
|
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<ApprovalLineTemplate[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isLoadingDefs, setIsLoadingDefs] = useState(false);
|
||||||
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 결재 유형 목록 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoadingDefs(true);
|
||||||
|
const res = await getApprovalDefinitions({ is_active: "Y" });
|
||||||
|
if (res.success && res.data) setDefinitions(res.data);
|
||||||
|
setIsLoadingDefs(false);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 결재 유형 변경 시 템플릿 목록 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDefinitionId) {
|
||||||
|
setTemplates([]);
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoadingTemplates(true);
|
||||||
|
const res = await getApprovalTemplates({
|
||||||
|
definition_id: Number(selectedDefinitionId),
|
||||||
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
if (res.success && res.data) setTemplates(res.data);
|
||||||
|
setIsLoadingTemplates(false);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [selectedDefinitionId]);
|
||||||
|
|
||||||
|
// 템플릿 선택 시 결재선 자동 세팅
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
const template = templates.find((t) => String(t.template_id) === selectedTemplateId);
|
||||||
|
if (!template?.steps) return;
|
||||||
|
|
||||||
|
const rows: ApproverRow[] = template.steps
|
||||||
|
.sort((a, b) => a.step_order - b.step_order)
|
||||||
|
.map((step) => ({
|
||||||
|
id: generateLocalId(),
|
||||||
|
approver_id: step.approver_user_id || "",
|
||||||
|
approver_name: step.approver_label || "",
|
||||||
|
approver_position: step.approver_position || "",
|
||||||
|
approver_dept: step.approver_dept_code || "",
|
||||||
|
approver_label: step.approver_label || `${step.step_order}차 결재`,
|
||||||
|
}));
|
||||||
|
setApprovers(rows);
|
||||||
|
}, [selectedTemplateId, templates]);
|
||||||
|
|
||||||
|
// eventDetail에서 definitionId 자동 세팅
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && eventDetail?.definitionId) {
|
||||||
|
setSelectedDefinitionId(String(eventDetail.definitionId));
|
||||||
|
}
|
||||||
|
}, [open, eventDetail]);
|
||||||
|
|
||||||
|
// 모달 닫힐 때 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setSelectedDefinitionId("");
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setApprovers([]);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleAddApprover = () => {
|
||||||
|
setApprovers((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: generateLocalId(),
|
||||||
|
approver_id: "",
|
||||||
|
approver_name: "",
|
||||||
|
approver_position: "",
|
||||||
|
approver_dept: "",
|
||||||
|
approver_label: `${prev.length + 1}차 결재`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveApprover = (id: string) => {
|
||||||
|
setApprovers((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproverChange = (id: string, field: keyof ApproverRow, value: string) => {
|
||||||
|
setApprovers((prev) =>
|
||||||
|
prev.map((a) => (a.id === id ? { ...a, [field]: value } : a))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("결재 제목을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (approvers.length === 0) {
|
||||||
|
setError("결재자를 1명 이상 추가해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emptyApprover = approvers.find((a) => !a.approver_id.trim());
|
||||||
|
if (emptyApprover) {
|
||||||
|
setError("모든 결재자의 사번/ID를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) {
|
||||||
|
setError("결재 대상 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await createApprovalRequest({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
definition_id: selectedDefinitionId ? Number(selectedDefinitionId) : undefined,
|
||||||
|
target_table: eventDetail.targetTable,
|
||||||
|
target_record_id: eventDetail.targetRecordId,
|
||||||
|
target_record_data: eventDetail.targetRecordData,
|
||||||
|
screen_id: eventDetail.screenId,
|
||||||
|
button_component_id: eventDetail.buttonComponentId,
|
||||||
|
approvers: approvers.map((a) => ({
|
||||||
|
approver_id: a.approver_id.trim(),
|
||||||
|
approver_name: a.approver_name.trim() || undefined,
|
||||||
|
approver_position: a.approver_position.trim() || undefined,
|
||||||
|
approver_dept: a.approver_dept.trim() || undefined,
|
||||||
|
approver_label: a.approver_label.trim() || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (res.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="space-y-3 sm:space-y-4">
|
||||||
|
{/* 결재 제목 */}
|
||||||
|
<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-description" className="text-xs sm:text-sm">
|
||||||
|
결재 사유
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="approval-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="결재 사유를 입력하세요 (선택사항)"
|
||||||
|
className="min-h-[60px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재 유형 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">결재 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDefinitionId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSelectedDefinitionId(v);
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setApprovers([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder={isLoadingDefs ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">유형 없음</SelectItem>
|
||||||
|
{definitions.map((def) => (
|
||||||
|
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
||||||
|
{def.definition_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재선 템플릿 선택 */}
|
||||||
|
{selectedDefinitionId && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">결재선 템플릿</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onValueChange={setSelectedTemplateId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder={isLoadingTemplates ? "로딩 중..." : "템플릿 선택 (선택사항)"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">직접 입력</SelectItem>
|
||||||
|
{templates.map((tmpl) => (
|
||||||
|
<SelectItem key={tmpl.template_id} value={String(tmpl.template_id)}>
|
||||||
|
{tmpl.template_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
템플릿 선택 시 결재선이 자동으로 채워집니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결재선 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
결재선 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleAddApprover}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
결재자 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{approvers.length === 0 && (
|
||||||
|
<p className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-xs">
|
||||||
|
결재자를 추가하거나 템플릿을 선택하세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{approvers.map((approver, idx) => (
|
||||||
|
<div key={approver.id} className="rounded-md border p-2 sm:p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-[10px] sm:text-xs">
|
||||||
|
{idx + 1}차 결재
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => handleRemoveApprover(approver.id)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs">
|
||||||
|
사번/ID <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={approver.approver_id}
|
||||||
|
onChange={(e) => handleApproverChange(approver.id, "approver_id", e.target.value)}
|
||||||
|
placeholder="사번 또는 ID"
|
||||||
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs">이름</Label>
|
||||||
|
<Input
|
||||||
|
value={approver.approver_name}
|
||||||
|
onChange={(e) => handleApproverChange(approver.id, "approver_name", e.target.value)}
|
||||||
|
placeholder="결재자 이름"
|
||||||
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs">직급</Label>
|
||||||
|
<Input
|
||||||
|
value={approver.approver_position}
|
||||||
|
onChange={(e) => handleApproverChange(approver.id, "approver_position", e.target.value)}
|
||||||
|
placeholder="직급"
|
||||||
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs">라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={approver.approver_label}
|
||||||
|
onChange={(e) => handleApproverChange(approver.id, "approver_label", e.target.value)}
|
||||||
|
placeholder="예: 팀장, 최종 결재"
|
||||||
|
className="h-7 text-[10px] sm:h-8 sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
</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}
|
||||||
|
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" />
|
||||||
|
요청 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"결재 요청"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalRequestModal;
|
||||||
|
|
@ -18,6 +18,7 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||||
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||||
|
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||||
|
|
||||||
// 🆕 제목 블록 타입
|
// 🆕 제목 블록 타입
|
||||||
interface TitleBlock {
|
interface TitleBlock {
|
||||||
|
|
@ -107,6 +108,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// 결재 유형 목록 상태
|
||||||
|
const [approvalDefinitions, setApprovalDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||||
|
const [approvalDefinitionsLoading, setApprovalDefinitionsLoading] = useState(false);
|
||||||
|
|
||||||
// 🆕 그룹화 컬럼 선택용 상태
|
// 🆕 그룹화 컬럼 선택용 상태
|
||||||
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
const [currentTableColumns, setCurrentTableColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||||
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
const [groupByColumnOpen, setGroupByColumnOpen] = useState(false);
|
||||||
|
|
@ -689,6 +694,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
fetchScreens();
|
fetchScreens();
|
||||||
}, [currentScreenCompanyCode]);
|
}, [currentScreenCompanyCode]);
|
||||||
|
|
||||||
|
// 결재 유형 목록 가져오기 (approval 액션일 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (localInputs.actionType !== "approval") return;
|
||||||
|
const fetchApprovalDefinitions = async () => {
|
||||||
|
setApprovalDefinitionsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getApprovalDefinitions({ is_active: "Y" });
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setApprovalDefinitions(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 조용히 실패
|
||||||
|
} finally {
|
||||||
|
setApprovalDefinitionsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchApprovalDefinitions();
|
||||||
|
}, [localInputs.actionType]);
|
||||||
|
|
||||||
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTableColumns = async () => {
|
const fetchTableColumns = async () => {
|
||||||
|
|
@ -831,6 +855,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
{/* 고급 기능 */}
|
{/* 고급 기능 */}
|
||||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
|
<SelectItem value="approval">결재 요청</SelectItem>
|
||||||
|
|
||||||
{/* 특수 기능 (필요 시 사용) */}
|
{/* 특수 기능 (필요 시 사용) */}
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
|
|
@ -3730,6 +3755,76 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 결재 요청(approval) 액션 설정 */}
|
||||||
|
{localInputs.actionType === "approval" && (
|
||||||
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||||
|
<h4 className="text-foreground text-sm font-medium">결재 요청 설정</h4>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
버튼 클릭 시 결재 요청 모달이 열립니다. 결재 유형을 선택하면 기본 결재선이 자동으로 세팅됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-definition" className="text-xs sm:text-sm">
|
||||||
|
결재 유형
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={String(component.componentConfig?.action?.approvalDefinitionId || "")}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.approvalDefinitionId", value ? Number(value) : null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder={approvalDefinitionsLoading ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">유형 없음 (직접 설정)</SelectItem>
|
||||||
|
{approvalDefinitions.map((def) => (
|
||||||
|
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
||||||
|
{def.definition_name}
|
||||||
|
{def.description ? ` - ${def.description}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
결재 유형을 선택하면 기본 결재선 템플릿이 자동 적용됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-target-table" className="text-xs sm:text-sm">
|
||||||
|
대상 테이블
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="approval-target-table"
|
||||||
|
placeholder="예: purchase_orders"
|
||||||
|
value={component.componentConfig?.action?.approvalTargetTable || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
결재 대상 레코드가 저장된 테이블명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="approval-record-id-field" className="text-xs sm:text-sm">
|
||||||
|
레코드 ID 필드명
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="approval-record-id-field"
|
||||||
|
placeholder="예: id, purchase_id"
|
||||||
|
value={component.componentConfig?.action?.approvalRecordIdField || "id"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.approvalRecordIdField", e.target.value)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
현재 선택된 레코드의 PK 컬럼명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 🆕 이벤트 발송 액션 설정 */}
|
{/* 🆕 이벤트 발송 액션 설정 */}
|
||||||
{localInputs.actionType === "event" && (
|
{localInputs.actionType === "event" && (
|
||||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,9 @@ export type ButtonActionType =
|
||||||
// 데이터 전달
|
// 데이터 전달
|
||||||
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
|
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
|
||||||
// 즉시 저장
|
// 즉시 저장
|
||||||
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT
|
| "quickInsert" // 선택한 데이터를 특정 테이블에 즉시 INSERT
|
||||||
|
// 결재 워크플로우
|
||||||
|
| "approval"; // 결재 요청을 생성합니다
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 타입 정의
|
* 컴포넌트 타입 정의
|
||||||
|
|
@ -339,6 +341,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
|
||||||
"control",
|
"control",
|
||||||
"transferData",
|
"transferData",
|
||||||
"quickInsert",
|
"quickInsert",
|
||||||
|
"approval",
|
||||||
];
|
];
|
||||||
return actionTypes.includes(value as ButtonActionType);
|
return actionTypes.includes(value as ButtonActionType);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue