789 lines
31 KiB
TypeScript
789 lines
31 KiB
TypeScript
"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>
|
|
);
|
|
}
|