feature/v2-renewal #400
|
|
@ -0,0 +1,788 @@
|
|||
"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 { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
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, Users, FileText, Loader2 } from "lucide-react";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
type ApprovalDefinition,
|
||||
type ApprovalLineTemplate,
|
||||
type ApprovalLineTemplateStep,
|
||||
getApprovalDefinitions,
|
||||
createApprovalDefinition,
|
||||
updateApprovalDefinition,
|
||||
deleteApprovalDefinition,
|
||||
getApprovalTemplates,
|
||||
getApprovalTemplate,
|
||||
createApprovalTemplate,
|
||||
updateApprovalTemplate,
|
||||
deleteApprovalTemplate,
|
||||
} from "@/lib/api/approval";
|
||||
|
||||
// ============================================================
|
||||
// 결재 유형 관리 탭
|
||||
// ============================================================
|
||||
|
||||
function DefinitionsTab() {
|
||||
const [definitions, setDefinitions] = useState<ApprovalDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingDef, setEditingDef] = useState<ApprovalDefinition | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
definition_name: "",
|
||||
definition_name_eng: "",
|
||||
description: "",
|
||||
max_steps: 3,
|
||||
allow_self_approval: false,
|
||||
allow_cancel: true,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApprovalDefinition | null>(null);
|
||||
|
||||
const fetchDefinitions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await getApprovalDefinitions({ search: searchTerm || undefined });
|
||||
if (res.success && res.data) {
|
||||
setDefinitions(res.data);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefinitions();
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingDef(null);
|
||||
setFormData({
|
||||
definition_name: "",
|
||||
definition_name_eng: "",
|
||||
description: "",
|
||||
max_steps: 3,
|
||||
allow_self_approval: false,
|
||||
allow_cancel: true,
|
||||
is_active: "Y",
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (def: ApprovalDefinition) => {
|
||||
setEditingDef(def);
|
||||
setFormData({
|
||||
definition_name: def.definition_name,
|
||||
definition_name_eng: def.definition_name_eng || "",
|
||||
description: def.description || "",
|
||||
max_steps: def.max_steps,
|
||||
allow_self_approval: def.allow_self_approval,
|
||||
allow_cancel: def.allow_cancel,
|
||||
is_active: def.is_active,
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.definition_name.trim()) {
|
||||
toast.warning("결재 유형명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
let res;
|
||||
if (editingDef) {
|
||||
res = await updateApprovalDefinition(editingDef.definition_id, formData);
|
||||
} else {
|
||||
res = await createApprovalDefinition(formData);
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(editingDef ? "수정되었습니다." : "등록되었습니다.");
|
||||
setEditOpen(false);
|
||||
fetchDefinitions();
|
||||
} else {
|
||||
toast.error(res.error || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const res = await deleteApprovalDefinition(deleteTarget.definition_id);
|
||||
if (res.success) {
|
||||
toast.success("삭제되었습니다.");
|
||||
setDeleteTarget(null);
|
||||
fetchDefinitions();
|
||||
} else {
|
||||
toast.error(res.error || "삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = definitions.filter(
|
||||
(d) =>
|
||||
d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(d.description || "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 + 등록 */}
|
||||
<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 w-[100px] text-center 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>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((def) => (
|
||||
<TableRow key={def.definition_id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-14 text-sm font-medium">{def.definition_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-14 text-sm">{def.description || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">{def.max_steps}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.allow_self_approval ? "default" : "secondary"}>
|
||||
{def.allow_self_approval ? "허용" : "불가"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.allow_cancel ? "default" : "secondary"}>
|
||||
{def.allow_cancel ? "허용" : "불가"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={def.is_active === "Y" ? "default" : "outline"}>
|
||||
{def.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(def)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => setDeleteTarget(def)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingDef ? "결재 유형 수정" : "결재 유형 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
결재 유형의 기본 정보를 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">유형명 *</Label>
|
||||
<Input
|
||||
value={formData.definition_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, definition_name: e.target.value }))}
|
||||
placeholder="예: 일반 결재, 긴급 결재"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">영문명</Label>
|
||||
<Input
|
||||
value={formData.definition_name_eng}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))}
|
||||
placeholder="예: General Approval"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="유형에 대한 설명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">최대 결재 단계</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={formData.max_steps}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">자가 결재 허용</Label>
|
||||
<Switch
|
||||
checked={formData.allow_self_approval}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_self_approval: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">회수 가능</Label>
|
||||
<Switch
|
||||
checked={formData.allow_cancel}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, allow_cancel: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||
<Switch
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||
/>
|
||||
</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} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{editingDef ? "수정" : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재 유형 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.definition_name}"을(를) 삭제하시겠습니까?
|
||||
<br />이 유형에 연결된 결재 요청이 있으면 삭제할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재선 템플릿 관리 탭
|
||||
// ============================================================
|
||||
|
||||
function TemplatesTab() {
|
||||
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 [formData, setFormData] = useState({
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null as number | null,
|
||||
is_active: "Y",
|
||||
steps: [] as Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[],
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingTpl(null);
|
||||
setFormData({
|
||||
template_name: "",
|
||||
description: "",
|
||||
definition_id: null,
|
||||
is_active: "Y",
|
||||
steps: [{ step_order: 1, 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,
|
||||
is_active: detail.is_active,
|
||||
steps: (detail.steps || []).map((s) => ({
|
||||
step_order: s.step_order,
|
||||
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,
|
||||
})),
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: [
|
||||
...p.steps,
|
||||
{
|
||||
step_order: p.steps.length + 1,
|
||||
approver_type: "user" as const,
|
||||
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, field: string, value: string) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.template_name.trim()) {
|
||||
toast.warning("템플릿명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (formData.steps.length === 0) {
|
||||
toast.warning("결재 단계를 최소 1개 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
template_name: formData.template_name,
|
||||
description: formData.description || undefined,
|
||||
definition_id: formData.definition_id || undefined,
|
||||
is_active: formData.is_active,
|
||||
steps: formData.steps,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (editingTpl) {
|
||||
res = await updateApprovalTemplate(editingTpl.template_id, payload);
|
||||
} else {
|
||||
res = await createApprovalTemplate(payload);
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 + 등록 */}
|
||||
<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-[100px] text-center text-sm font-semibold">단계 수</TableHead>
|
||||
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 w-[120px] 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">{tpl.definition_name || "-"}</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant="secondary">{tpl.steps?.length || 0}단계</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-14 text-center text-sm">
|
||||
<Badge variant={tpl.is_active === "Y" ? "default" : "outline"}>
|
||||
{tpl.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(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>
|
||||
)}
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<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 className="text-xs sm:text-sm">템플릿명 *</Label>
|
||||
<Input
|
||||
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 className="text-xs sm:text-sm">설명</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="템플릿에 대한 설명"
|
||||
className="h-8 text-xs sm:h-10 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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">활성 상태</Label>
|
||||
<Switch
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(v) => setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))}
|
||||
/>
|
||||
</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) => (
|
||||
<div key={idx} className="bg-muted/30 space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{step.step_order}단계</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
onClick={() => removeStep(idx)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">결재자 유형</Label>
|
||||
<Select value={step.approver_type} onValueChange={(v) => updateStep(idx, "approver_type", v)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">사용자 지정</SelectItem>
|
||||
<SelectItem value="position">직급 지정</SelectItem>
|
||||
<SelectItem value="dept">부서 지정</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 라벨</Label>
|
||||
<Input
|
||||
value={step.approver_label || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_label", e.target.value)}
|
||||
placeholder="예: 팀장"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{step.approver_type === "user" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">사용자 ID</Label>
|
||||
<Input
|
||||
value={step.approver_user_id || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_user_id", e.target.value)}
|
||||
placeholder="고정 결재자 ID (비워두면 요청 시 지정)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.approver_type === "position" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">직급</Label>
|
||||
<Input
|
||||
value={step.approver_position || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_position", e.target.value)}
|
||||
placeholder="예: 부장, 이사"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.approver_type === "dept" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">부서 코드</Label>
|
||||
<Input
|
||||
value={step.approver_dept_code || ""}
|
||||
onChange={(e) => updateStep(idx, "approver_dept_code", e.target.value)}
|
||||
placeholder="예: DEPT001"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
{editingTpl ? "수정" : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재선 템플릿 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{deleteTarget?.template_name}"을(를) 삭제하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 페이지
|
||||
// ============================================================
|
||||
|
||||
export default function ApprovalManagementPage() {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">결재 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
결재 유형과 결재선 템플릿을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="definitions" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="definitions" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
결재 유형
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
결재선 템플릿
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="definitions">
|
||||
<DefinitionsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<TemplatesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { 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";
|
||||
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";
|
||||
|
||||
// 결재자 행 타입
|
||||
interface ApproverRow {
|
||||
id: string; // 로컬 임시 ID
|
||||
approver_id: string;
|
||||
approver_name: string;
|
||||
approver_position: string;
|
||||
approver_dept: string;
|
||||
approver_label: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
position_name: string;
|
||||
dept_name: string;
|
||||
}
|
||||
|
||||
// 모달 열기 이벤트로 전달되는 데이터
|
||||
export interface ApprovalModalEventDetail {
|
||||
targetTable: string;
|
||||
targetRecordId: string;
|
||||
|
|
@ -42,8 +40,17 @@ interface ApprovalRequestModalProps {
|
|||
eventDetail?: ApprovalModalEventDetail | null;
|
||||
}
|
||||
|
||||
function generateLocalId(): string {
|
||||
return `row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
interface UserSearchResult {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
position_name?: string;
|
||||
dept_name?: string;
|
||||
dept_code?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||
|
|
@ -53,107 +60,97 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
}) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedDefinitionId, setSelectedDefinitionId] = useState<string>("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
|
||||
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]);
|
||||
// 사용자 검색 상태
|
||||
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("");
|
||||
setSelectedDefinitionId("");
|
||||
setSelectedTemplateId("");
|
||||
setApprovalMode("sequential");
|
||||
setApprovers([]);
|
||||
setError(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleAddApprover = () => {
|
||||
// 사용자 검색 (디바운스)
|
||||
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 users: UserSearchResult[] = Array.isArray(data) ? data : [];
|
||||
// 이미 추가된 결재자 제외
|
||||
const existingIds = new Set(approvers.map((a) => a.user_id));
|
||||
setSearchResults(users.filter((u) => !existingIds.has(u.user_id)));
|
||||
} 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: generateLocalId(),
|
||||
approver_id: "",
|
||||
approver_name: "",
|
||||
approver_position: "",
|
||||
approver_dept: "",
|
||||
approver_label: `${prev.length + 1}차 결재`,
|
||||
id: genId(),
|
||||
user_id: user.user_id,
|
||||
user_name: user.user_name,
|
||||
position_name: user.position_name || "",
|
||||
dept_name: user.dept_name || "",
|
||||
},
|
||||
]);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
setSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveApprover = (id: string) => {
|
||||
const removeApprover = (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 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 () => {
|
||||
|
|
@ -165,14 +162,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
setError("결재자를 1명 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
const emptyApprover = approvers.find((a) => !a.approver_id.trim());
|
||||
if (emptyApprover) {
|
||||
setError("모든 결재자의 사번/ID를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) {
|
||||
setError("결재 대상 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||
setError("결재 대상 정보가 없습니다. 레코드를 선택 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -182,24 +173,30 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
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,
|
||||
target_record_data: {
|
||||
...eventDetail.targetRecordData,
|
||||
approval_mode: approvalMode,
|
||||
},
|
||||
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,
|
||||
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}차 결재`
|
||||
: "동시 결재",
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
toast.success("결재 요청이 완료되었습니다.");
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError(res.error || res.message || "결재 요청에 실패했습니다.");
|
||||
|
|
@ -210,13 +207,13 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">결재 요청</DialogTitle>
|
||||
<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 className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
|
||||
{/* 결재 제목 */}
|
||||
<div>
|
||||
<Label htmlFor="approval-title" className="text-xs sm:text-sm">
|
||||
|
|
@ -233,11 +230,11 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
|
||||
{/* 결재 사유 */}
|
||||
<div>
|
||||
<Label htmlFor="approval-description" className="text-xs sm:text-sm">
|
||||
<Label htmlFor="approval-desc" className="text-xs sm:text-sm">
|
||||
결재 사유
|
||||
</Label>
|
||||
<Textarea
|
||||
id="approval-description"
|
||||
id="approval-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="결재 사유를 입력하세요 (선택사항)"
|
||||
|
|
@ -245,146 +242,203 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 결재 유형 선택 */}
|
||||
{/* 결재 방식 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재 유형</Label>
|
||||
<Select
|
||||
value={selectedDefinitionId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedDefinitionId(v === "none" ? "" : v);
|
||||
setSelectedTemplateId("");
|
||||
setApprovers([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={isLoadingDefs ? "로딩 중..." : "결재 유형 선택 (선택사항)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">유형 없음</SelectItem>
|
||||
{definitions.map((def) => (
|
||||
<SelectItem key={def.definition_id} value={String(def.definition_id)}>
|
||||
{def.definition_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
|
||||
{/* 결재선 템플릿 선택 */}
|
||||
{selectedDefinitionId && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">결재선 템플릿</Label>
|
||||
<Select
|
||||
value={selectedTemplateId}
|
||||
onValueChange={(v) => setSelectedTemplateId(v === "none" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={isLoadingTemplates ? "로딩 중..." : "템플릿 선택 (선택사항)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</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>
|
||||
결재자 <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>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{approvers.length}명 선택됨
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{approvers.length === 0 && (
|
||||
<p className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-xs">
|
||||
결재자를 추가하거나 템플릿을 선택하세요
|
||||
</p>
|
||||
{/* 검색 입력 */}
|
||||
<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.user_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"
|
||||
>
|
||||
<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.user_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.user_id})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-[10px]">
|
||||
{[user.dept_name, user.position_name].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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 선택된 결재자 목록 */}
|
||||
{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="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleRemoveApprover(approver.id)}
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeApprover(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>
|
||||
))}
|
||||
|
||||
{/* 결재 흐름 시각화 */}
|
||||
{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 && (
|
||||
<p className="text-destructive text-xs">{error}</p>
|
||||
<div className="bg-destructive/10 rounded-md p-2">
|
||||
<p className="text-destructive text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -399,7 +453,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || approvers.length === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
@ -408,7 +462,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
요청 중...
|
||||
</>
|
||||
) : (
|
||||
"결재 요청"
|
||||
`결재 상신 (${approvers.length}명)`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -3797,13 +3797,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="approval-target-table"
|
||||
placeholder="예: purchase_orders"
|
||||
value={component.componentConfig?.action?.approvalTargetTable || ""}
|
||||
placeholder={currentTableName || "예: purchase_orders"}
|
||||
value={component.componentConfig?.action?.approvalTargetTable || currentTableName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.approvalTargetTable", e.target.value)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
readOnly={!!currentTableName && !component.componentConfig?.action?.approvalTargetTable}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
결재 대상 레코드가 저장된 테이블명
|
||||
{currentTableName
|
||||
? `현재 화면 테이블 "${currentTableName}" 자동 적용됨`
|
||||
: "결재 대상 레코드가 저장된 테이블명"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -585,7 +585,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
toast.dismiss();
|
||||
|
||||
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"];
|
||||
if (!silentActions.includes(actionConfig.type)) {
|
||||
currentLoadingToastRef.current = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
|
|
|
|||
|
|
@ -451,6 +451,9 @@ export class ButtonActionExecutor {
|
|||
case "event":
|
||||
return await this.handleEvent(config, context);
|
||||
|
||||
case "approval":
|
||||
return this.handleApproval(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -7567,6 +7570,42 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 요청 모달 열기
|
||||
*/
|
||||
private static handleApproval(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||||
try {
|
||||
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
|
||||
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
|
||||
const recordIdField = (config as any).approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow[recordIdField] || "";
|
||||
|
||||
if (!targetRecordId) {
|
||||
toast.warning("결재 대상 레코드를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: String(targetRecordId),
|
||||
targetRecordData: selectedRow,
|
||||
definitionId: (config as any).approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
buttonComponentId: context.formData?.buttonId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[handleApproval] 결재 요청 오류:", error);
|
||||
toast.error("결재 요청 모달을 열 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -7691,4 +7730,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
event: {
|
||||
type: "event",
|
||||
},
|
||||
approval: {
|
||||
type: "approval",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -976,6 +976,35 @@ export class ImprovedButtonActionExecutor {
|
|||
return await this.executeTransferDataAction(buttonConfig, formData, context);
|
||||
}
|
||||
|
||||
// 결재 요청 모달 열기
|
||||
if (buttonConfig.actionType === "approval") {
|
||||
const actionConfig = (buttonConfig as any).componentConfig?.action || buttonConfig;
|
||||
const selectedRow = context.selectedRows?.[0] || context.formData || formData;
|
||||
const targetTable = actionConfig.approvalTargetTable || "";
|
||||
const recordIdField = actionConfig.approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow[recordIdField] || "";
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: String(targetRecordId),
|
||||
targetRecordData: selectedRow,
|
||||
definitionId: actionConfig.approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
buttonComponentId: context.buttonId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "결재 요청 모달이 열렸습니다",
|
||||
executionTime: performance.now() - startTime,
|
||||
data: { actionType: "approval", targetTable, targetRecordId },
|
||||
};
|
||||
}
|
||||
|
||||
// 기존 액션들 (임시 구현)
|
||||
const result = {
|
||||
success: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue