부서 권한 그룹 권한 관리
Build & Deploy to K8s / build-and-deploy (push) Successful in 7s

This commit is contained in:
chpark
2026-04-22 03:14:01 +09:00
committed by chpark
parent 868ddac9a3
commit 2c57dc8cda
15 changed files with 3031 additions and 438 deletions
@@ -0,0 +1,952 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Building2,
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Globe,
History,
Info,
Play,
Plus,
Search,
Star,
Upload,
Users,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
import * as departmentAPI from "@/lib/api/department";
import { getCompanyList } from "@/lib/api/company";
import type { Department, DepartmentMember } from "@/types/department";
import type { Company } from "@/types/company";
interface DeptDetailDraft {
dept_code: string;
dept_name: string;
parent_dept_code: string | null;
dept_type: string;
org_system: string;
short_name: string;
approval_manager: string;
dept_manager: string;
org_head: string;
zipcode: string;
address1: string;
address2: string;
status: "active" | "inactive";
start_date: string;
end_date: string;
erp_managed: "Y" | "N";
show_in_chart: "Y" | "N";
sort_order: number;
}
const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
dept_code: "",
dept_name: "",
parent_dept_code: null,
dept_type: "dept",
org_system: "",
short_name: "",
approval_manager: "",
dept_manager: "",
org_head: "",
zipcode: "",
address1: "",
address2: "",
status: "active",
start_date: new Date().toISOString().slice(0, 10),
end_date: "",
erp_managed: "Y",
show_in_chart: "Y",
sort_order: 10,
});
export default function DeptMngListPage() {
const { toast } = useToast();
const { user } = useAuth();
// ── 회사 선택 / 기준일 ────────────────────────────────
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
const [periodMode, setPeriodMode] = useState<"all" | "date">("date");
const [baseDate, setBaseDate] = useState<string>(new Date().toISOString().slice(0, 10));
const [searchKeyword, setSearchKeyword] = useState("");
// ── 부서 트리 ─────────────────────────────────────────
const [departments, setDepartments] = useState<Department[]>([]);
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
const [selectedCode, setSelectedCode] = useState<string | null>(null);
const [isTreeLoading, setIsTreeLoading] = useState(false);
// ── 상세정보 ─────────────────────────────────────────
const [draft, setDraft] = useState<DeptDetailDraft>(() => emptyDraft());
const [originalDraft, setOriginalDraft] = useState<DeptDetailDraft | null>(null);
const [isNewMode, setIsNewMode] = useState(false);
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const selectedCompany = useMemo(
() => companies.find((c) => c.company_code === selectedCompanyCode) || null,
[companies, selectedCompanyCode],
);
// ── 회사 목록 로드 (SUPER_ADMIN 은 전체, 그 외엔 본인 회사) ──
useEffect(() => {
(async () => {
try {
const list = await getCompanyList();
setCompanies(list);
const userCompany = (user as any)?.company_code;
if (userCompany && userCompany !== "*") {
setSelectedCompanyCode(userCompany);
} else if (list.length > 0) {
setSelectedCompanyCode(list[0].company_code);
}
} catch (err) {
console.error("회사 목록 로드 실패", err);
}
})();
}, [user]);
// ── 부서 목록 로드 ───────────────────────────────────
const loadDepartments = useCallback(async () => {
if (!selectedCompanyCode) return;
setIsTreeLoading(true);
try {
const res = await departmentAPI.getDepartments(selectedCompanyCode);
if (res.success && (res as any).data) {
setDepartments((res as any).data);
} else {
setDepartments([]);
}
} finally {
setIsTreeLoading(false);
}
}, [selectedCompanyCode]);
useEffect(() => {
loadDepartments();
}, [loadDepartments]);
// ── 부서원 로드 ──────────────────────────────────────
useEffect(() => {
if (activeTab !== "members" || !selectedCode || isNewMode) {
setMembers([]);
return;
}
(async () => {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (res.success && (res as any).data) setMembers((res as any).data);
})();
}, [activeTab, selectedCode, isNewMode]);
// ── 트리 구성 ────────────────────────────────────────
const filteredDepts = useMemo(() => {
if (!searchKeyword.trim()) return departments;
const kw = searchKeyword.toLowerCase();
return departments.filter(
(d) =>
d.dept_name?.toLowerCase().includes(kw) ||
d.dept_code?.toLowerCase().includes(kw),
);
}, [departments, searchKeyword]);
const childrenOf = useCallback(
(parent: string | null) =>
filteredDepts
.filter((d) => (d.parent_dept_code ?? null) === parent)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0) || (a.dept_name || "").localeCompare(b.dept_name || "")),
[filteredDepts],
);
const expandAll = () => {
setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
};
const collapseAll = () => setExpandedSet(new Set());
const toggleExpand = (code: string) => {
setExpandedSet((prev) => {
const next = new Set(prev);
next.has(code) ? next.delete(code) : next.add(code);
return next;
});
};
// ── 선택 시 상세정보 채우기 ───────────────────────────
const handleSelectDepartment = (dept: Department) => {
setSelectedCode(dept.dept_code);
setIsNewMode(false);
const loaded: DeptDetailDraft = {
...emptyDraft(selectedCompanyCode),
dept_code: dept.dept_code,
dept_name: dept.dept_name,
parent_dept_code: dept.parent_dept_code ?? null,
short_name: dept.short_name ?? "",
dept_type: dept.dept_type ?? "dept",
org_system: dept.org_system ?? "",
approval_manager: dept.approval_manager ?? "",
dept_manager: dept.dept_manager ?? "",
org_head: dept.org_head ?? "",
zipcode: dept.zipcode ?? "",
address1: dept.address1 ?? "",
address2: dept.address2 ?? "",
start_date: (dept.start_date ?? "").slice(0, 10),
end_date: (dept.end_date ?? "").slice(0, 10),
erp_managed: (dept.erp_managed as "Y" | "N") ?? "Y",
show_in_chart: (dept.show_in_chart as "Y" | "N") ?? "Y",
sort_order: dept.sort_order ?? 10,
status: (dept.status as "active" | "inactive") ?? "active",
};
setDraft(loaded);
setOriginalDraft(loaded);
};
const handleAddNew = (parentCode: string | null = null) => {
setSelectedCode(null);
setIsNewMode(true);
setActiveTab("info");
setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });
setOriginalDraft(null);
};
const handleClearDetail = () => {
setSelectedCode(null);
setIsNewMode(false);
setDraft(emptyDraft(selectedCompanyCode));
setOriginalDraft(null);
};
// ── 저장 ─────────────────────────────────────────────
const handleSave = async () => {
if (!draft.dept_name.trim()) {
toast({ title: "부서명을 입력해주세요", variant: "destructive" });
return;
}
if (!selectedCompanyCode) {
toast({ title: "회사를 선택해주세요", variant: "destructive" });
return;
}
// 기본정보 탭 전체 필드를 payload 로 전달 — DepartmentFormData 와 1:1
const payload = {
dept_name: draft.dept_name,
parent_dept_code: draft.parent_dept_code,
short_name: draft.short_name,
dept_type: draft.dept_type,
org_system: draft.org_system || null,
approval_manager: draft.approval_manager,
dept_manager: draft.dept_manager,
org_head: draft.org_head,
zipcode: draft.zipcode,
address1: draft.address1,
address2: draft.address2,
start_date: draft.start_date || null,
end_date: draft.end_date || null,
erp_managed: draft.erp_managed,
show_in_chart: draft.show_in_chart,
sort_order: draft.sort_order,
status: draft.status,
};
try {
if (isNewMode) {
const res = await departmentAPI.createDepartment(selectedCompanyCode, payload);
if (res.success) {
toast({ title: "부서가 생성되었습니다" });
await loadDepartments();
const created = (res as any).data as Department | undefined;
if (created) handleSelectDepartment(created);
else handleClearDetail();
} else {
toast({ title: "생성 실패", description: (res as any).error, variant: "destructive" });
}
} else if (selectedCode) {
const res = await departmentAPI.updateDepartment(selectedCode, payload);
if (res.success) {
toast({ title: "부서가 수정되었습니다" });
await loadDepartments();
setOriginalDraft(draft);
} else {
toast({ title: "수정 실패", description: (res as any).error, variant: "destructive" });
}
}
} catch (err: any) {
toast({ title: "오류", description: err?.message, variant: "destructive" });
}
};
// ── 삭제 ─────────────────────────────────────────────
const handleDelete = async () => {
if (!selectedCode) return;
try {
const res = await departmentAPI.deleteDepartment(selectedCode);
if (res.success) {
toast({ title: "부서가 삭제되었습니다" });
await loadDepartments();
handleClearDetail();
} else {
toast({ title: "삭제 실패", description: (res as any).error, variant: "destructive" });
}
} finally {
setDeleteConfirmOpen(false);
}
};
const isDirty = originalDraft
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
// ─────────────────────────────────────────────────────
// 렌더
// ─────────────────────────────────────────────────────
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background text-sm">
{/* 상단 타이틀 바 */}
<div className="flex items-center justify-between border-b px-5 py-3">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold tracking-tight"></h1>
<Button size="icon" variant="ghost" className="h-7 w-7 text-sky-600">
<Info className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-sky-600">
<Play className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
<Upload className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleAddNew(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
<History className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Star className="h-4 w-4" />
</Button>
</div>
</div>
{/* 안내 배너 */}
<div className="flex items-center gap-2 border-b bg-sky-50/60 px-5 py-2 text-xs text-sky-800 dark:bg-sky-950/30 dark:text-sky-200">
<Info className="h-3.5 w-3.5 shrink-0" />
<span> () , '부서/팀/임시' .</span>
<Button variant="ghost" size="icon" className="ml-auto h-5 w-5">
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 본문 */}
<div className="flex min-h-0 flex-1">
{/* 좌측 트리 패널 */}
<aside className="flex w-[340px] shrink-0 flex-col border-r">
{/* 기준일 / 회사 / 검색 */}
<div className="space-y-3 border-b p-3">
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem key={c.company_code} value={c.company_code}>
{c.company_code}. {c.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="코드/사업장/부서명을 입력하세요."
className="h-8 pl-7 text-xs"
/>
</div>
<div className="flex items-center justify-end gap-1 text-xs">
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground" onClick={expandAll}>
<ChevronDown className="ml-0.5 h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground" onClick={collapseAll}>
<ChevronDown className="ml-0.5 h-3 w-3" />
</Button>
</div>
</div>
{/* 트리 */}
<div className="flex-1 overflow-auto p-2">
{isTreeLoading ? (
<div className="py-6 text-center text-xs text-muted-foreground"> ...</div>
) : !selectedCompany ? (
<div className="py-6 text-center text-xs text-muted-foreground"> </div>
) : (
<>
{/* 회사 루트 노드 */}
<div className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-bold text-sky-700 dark:text-sky-300">
<ChevronDown className="h-3.5 w-3.5" />
<Building2 className="h-3.5 w-3.5" />
<span>{selectedCompany.company_code}. {selectedCompany.company_name}</span>
</div>
{/* 사업장 (현재는 회사=사업장 1:1 가정) */}
<div className="ml-3">
<div className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-semibold text-sky-700 dark:text-sky-300">
<ChevronDown className="h-3.5 w-3.5" />
<Building2 className="h-3.5 w-3.5" />
<span>{selectedCompany.company_code}. {selectedCompany.company_name}</span>
</div>
{/* 부서 트리 */}
<div className="ml-3">
<DeptTree
items={childrenOf(null)}
allDepts={filteredDepts}
expanded={expandedSet}
selectedCode={selectedCode}
onToggle={toggleExpand}
onSelect={handleSelectDepartment}
/>
{childrenOf(null).length === 0 && (
<div className="px-2 py-3 text-xs text-muted-foreground"> .</div>
)}
</div>
</div>
</>
)}
</div>
</aside>
{/* 우측 상세 패널 */}
<section className="flex min-w-0 flex-1 flex-col">
{/* 상세 헤더 */}
<div className="flex items-center justify-between border-b px-5 py-2.5">
<div className="flex items-center gap-2 text-sm font-semibold">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-sky-500" />
</div>
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 bg-sky-500 text-xs text-white hover:bg-sky-600"
onClick={handleSave}
disabled={!isNewMode && !selectedCode}
>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setDeleteConfirmOpen(true)}
disabled={isNewMode || !selectedCode}
>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleClearDetail}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 탭 */}
<div className="flex items-center gap-5 border-b px-5">
<button
className={cn(
"relative py-2.5 text-sm font-medium transition-colors",
activeTab === "info" ? "text-sky-600 dark:text-sky-400" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab("info")}
>
{activeTab === "info" && <span className="absolute inset-x-0 -bottom-px h-0.5 bg-sky-500" />}
</button>
<button
className={cn(
"relative py-2.5 text-sm font-medium transition-colors",
activeTab === "members" ? "text-sky-600 dark:text-sky-400" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab("members")}
disabled={isNewMode}
>
{activeTab === "members" && <span className="absolute inset-x-0 -bottom-px h-0.5 bg-sky-500" />}
</button>
</div>
{/* 탭 바디 */}
<div className="min-h-0 flex-1 overflow-auto p-6">
{activeTab === "info" ? (
<BasicInfoForm
draft={draft}
setDraft={setDraft}
companyLabel={
selectedCompany ? `${selectedCompany.company_code}. ${selectedCompany.company_name}` : ""
}
parentLabel={
draft.parent_dept_code
? (departments.find((d) => d.dept_code === draft.parent_dept_code)?.dept_name ?? draft.parent_dept_code)
: "-"
}
/>
) : (
<MembersPanel members={members} />
)}
</div>
</section>
</div>
{/* 삭제 확인 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<p className="text-sm">
<span className="font-semibold">{draft.dept_name}</span> ?
</p>
<p className="text-xs text-muted-foreground"> . .</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}></Button>
<Button variant="destructive" onClick={handleDelete}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ───────────────────────────────────────────────────────
// 트리 (재귀)
// ───────────────────────────────────────────────────────
function DeptTree({
items,
allDepts,
expanded,
selectedCode,
onToggle,
onSelect,
}: {
items: Department[];
allDepts: Department[];
expanded: Set<string>;
selectedCode: string | null;
onToggle: (code: string) => void;
onSelect: (d: Department) => void;
}) {
return (
<div>
{items.map((dept) => {
const sub = allDepts.filter((d) => d.parent_dept_code === dept.dept_code);
const hasSub = sub.length > 0;
const isOpen = expanded.has(dept.dept_code);
const isActive = selectedCode === dept.dept_code;
return (
<div key={dept.dept_code}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-1.5 py-1 text-xs",
isActive ? "bg-sky-100 font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300" : "hover:bg-muted",
)}
onClick={() => onSelect(dept)}
>
{hasSub ? (
<button
onClick={(e) => {
e.stopPropagation();
onToggle(dept.dept_code);
}}
className="flex h-3.5 w-3.5 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
) : (
<span className="inline-block h-3.5 w-3.5" />
)}
{hasSub && isOpen ? (
<FolderOpen className="h-3.5 w-3.5 text-amber-500" />
) : (
<Folder className="h-3.5 w-3.5 text-amber-500" />
)}
<span className="truncate">
{dept.dept_code}. {dept.dept_name}
</span>
{typeof dept.member_count === "number" && dept.member_count > 0 && (
<span className="ml-auto flex items-center gap-0.5 text-[10px] text-muted-foreground">
<Users className="h-2.5 w-2.5" /> {dept.member_count}
</span>
)}
</div>
{hasSub && isOpen && (
<div className="ml-4">
<DeptTree
items={sub}
allDepts={allDepts}
expanded={expanded}
selectedCode={selectedCode}
onToggle={onToggle}
onSelect={onSelect}
/>
</div>
)}
</div>
);
})}
</div>
);
}
// ───────────────────────────────────────────────────────
// 기본정보 폼
// ───────────────────────────────────────────────────────
function BasicInfoForm({
draft,
setDraft,
companyLabel,
parentLabel,
}: {
draft: DeptDetailDraft;
setDraft: React.Dispatch<React.SetStateAction<DeptDetailDraft>>;
companyLabel: string;
parentLabel: string;
}) {
const update = <K extends keyof DeptDetailDraft>(key: K, value: DeptDetailDraft[K]) =>
setDraft((prev) => ({ ...prev, [key]: value }));
return (
<div className="mx-auto max-w-4xl">
<div className="grid grid-cols-[120px_1fr] gap-x-4 gap-y-0 rounded-md border bg-card">
<Row label="회사">
<div className="py-1 text-sm">{companyLabel || "-"}</div>
</Row>
<Row label="사업장">
<div className="py-1 text-sm">{companyLabel || "-"}</div>
</Row>
<Row label="상위부서">
<div className="flex items-center gap-1">
<Input value={parentLabel} readOnly className="h-8 flex-1 bg-muted/30 text-sm" />
<Button variant="outline" size="icon" className="h-8 w-8" type="button">
<Building2 className="h-3.5 w-3.5" />
</Button>
</div>
</Row>
<Row label="부서코드">
<Input
value={draft.dept_code}
onChange={(e) => update("dept_code", e.target.value)}
placeholder="저장 시 자동 부여 (DEPT_n)"
className="h-8 text-sm"
readOnly={!!draft.dept_code}
/>
</Row>
<Row label="부서유형">
<div className="flex items-center gap-2">
<Select value={draft.dept_type} onValueChange={(v) => update("dept_type", v)}>
<SelectTrigger className="h-8 w-[140px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dept"></SelectItem>
<SelectItem value="team"></SelectItem>
<SelectItem value="temp"></SelectItem>
</SelectContent>
</Select>
<Select value={draft.org_system || "none"} onValueChange={(v) => update("org_system", v === "none" ? "" : v)}>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue placeholder="조직체계 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="hr"></SelectItem>
<SelectItem value="sales"></SelectItem>
</SelectContent>
</Select>
</div>
</Row>
<Row label="부서명" required>
<div className="flex items-center gap-1">
<Input
value={draft.dept_name}
onChange={(e) => update("dept_name", e.target.value)}
className="h-8 flex-1 bg-rose-50/40 text-sm dark:bg-rose-950/20"
placeholder="부서명을 입력하세요"
/>
<Button variant="outline" size="icon" className="h-8 w-8" type="button" title="다국어">
<Globe className="h-3.5 w-3.5" />
</Button>
</div>
</Row>
<Row label="부서약칭">
<Input
value={draft.short_name}
onChange={(e) => update("short_name", e.target.value)}
className="h-8 text-sm"
/>
</Row>
<Row label="결재 관리자" hint>
<PickerField
value={draft.approval_manager}
onChange={(v) => update("approval_manager", v)}
placeholder="사용자 이름을 입력해주세요."
/>
</Row>
<Row label="부서 관리자">
<PickerField
value={draft.dept_manager}
onChange={(v) => update("dept_manager", v)}
placeholder="사용자 이름을 입력해주세요."
/>
</Row>
<Row label="조직장" hint>
<PickerField
value={draft.org_head}
onChange={(v) => update("org_head", v)}
placeholder="사용자 이름을 입력해주세요."
/>
</Row>
<Row label="부서주소">
<div className="space-y-1">
<div className="flex gap-1">
<Input
value={draft.zipcode}
onChange={(e) => update("zipcode", e.target.value)}
className="h-8 w-[100px] text-sm"
/>
<Button variant="outline" size="sm" className="h-8 text-xs" type="button"></Button>
</div>
<Input
value={draft.address1}
onChange={(e) => update("address1", e.target.value)}
className="h-8 text-sm"
/>
<Input
value={draft.address2}
onChange={(e) => update("address2", e.target.value)}
className="h-8 text-sm"
/>
</div>
</Row>
<Row label="사용여부">
<RadioGroup
value={draft.status}
onValueChange={(v) => update("status", v as "active" | "inactive")}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="active" id="status-active" className="h-3.5 w-3.5" />
<Label htmlFor="status-active" className="text-sm"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="inactive" id="status-inactive" className="h-3.5 w-3.5" />
<Label htmlFor="status-inactive" className="text-sm"></Label>
</div>
</RadioGroup>
</Row>
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
</div>
</div>
</Row>
<Row label="ERP관리부서" hint>
<div className="flex items-center gap-8">
<RadioGroup
value={draft.erp_managed}
onValueChange={(v) => update("erp_managed", v as "Y" | "N")}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id="erp-y" className="h-3.5 w-3.5" />
<Label htmlFor="erp-y" className="text-sm"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id="erp-n" className="h-3.5 w-3.5" />
<Label htmlFor="erp-n" className="text-sm"></Label>
</div>
</RadioGroup>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground"></Label>
<RadioGroup
value={draft.show_in_chart}
onValueChange={(v) => update("show_in_chart", v as "Y" | "N")}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id="chart-y" className="h-3.5 w-3.5" />
<Label htmlFor="chart-y" className="text-sm"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id="chart-n" className="h-3.5 w-3.5" />
<Label htmlFor="chart-n" className="text-sm"></Label>
</div>
</RadioGroup>
</div>
</div>
</Row>
<Row label="정렬">
<Input
type="number"
value={draft.sort_order}
onChange={(e) => update("sort_order", Number(e.target.value) || 0)}
className="h-8 text-sm"
/>
</Row>
</div>
</div>
);
}
function Row({
label,
children,
required,
hint,
}: {
label: string;
children: React.ReactNode;
required?: boolean;
hint?: boolean;
}) {
return (
<>
<div className="flex items-center gap-1 border-b bg-muted/30 px-3 py-2.5 text-xs font-medium last:border-b-0">
{hint && <Info className="h-3 w-3 text-sky-500" />}
<span>{label}</span>
{required && <span className="text-destructive">*</span>}
</div>
<div className="flex items-center border-b px-3 py-1.5 last:border-b-0">{children}</div>
</>
);
}
function PickerField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="flex items-center gap-1">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 flex-1 text-sm"
placeholder={placeholder}
/>
<Button variant="outline" size="icon" className="h-8 w-8" type="button">
<Users className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// ───────────────────────────────────────────────────────
// 부서원 패널
// ───────────────────────────────────────────────────────
function MembersPanel({ members }: { members: DepartmentMember[] }) {
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {members.length}</div>
</div>
<div className="divide-y rounded-md border bg-card">
{members.length === 0 ? (
<div className="py-10 text-center text-xs text-muted-foreground"> .</div>
) : (
members.map((m) => (
<div key={m.user_id} className="flex items-center justify-between px-4 py-2.5">
<div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{m.user_name}</span>
<span className="text-xs text-muted-foreground">({m.user_id})</span>
{m.is_primary && (
<Badge variant="default" className="h-4 gap-0.5 px-1.5 text-[10px]">
<Star className="h-2.5 w-2.5" />
</Badge>
)}
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
{m.position_name && <span>{m.position_name}</span>}
{m.email && <span>{m.email}</span>}
{m.phone && <span>{m.phone}</span>}
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,123 +1,172 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { UserAuthTable } from "@/components/admin/UserAuthTable";
import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
import { userAPI } from "@/lib/api/user";
import { Button } from "@/components/ui/button";
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RoleFormModal } from "@/components/admin/RoleFormModal";
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
import { useRouter } from "next/navigation";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 사용자 권한 관리 페이지
* URL: /admin/userAuth
* 권한 그룹 관리 페이지
* URL: /admin/roles
*
* 최고 관리자만 접근 가능
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
* shadcn/ui 스타일 가이드 적용
*
* 기능:
* - 회사별 권한 그룹 목록 조회
* - 권한 그룹 생성/수정/삭제
* - 멤버 관리 (Dual List Box)
* - 메뉴 권한 설정 (CRUD 권한)
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
*/
export default function UserAuthPage() {
export default function RolesPage() {
const { user: currentUser } = useAuth();
const router = useRouter();
// 최고 관리자 여부
// 회사 관리자 또는 최고 관리자 여부
const isAdmin =
(currentUser?.company_code === "*" && currentUser?.user_type === "SUPER_ADMIN") ||
currentUser?.user_type === "COMPANY_ADMIN";
const isSuperAdmin = currentUser?.company_code === "*" && currentUser?.user_type === "SUPER_ADMIN";
// 상태 관리
const [users, setUsers] = useState<any[]>([]);
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paginationInfo, setPaginationInfo] = useState({
currentPage: 1,
pageSize: 20,
totalItems: 0,
totalPages: 0,
// 회사 필터 (최고 관리자 전용)
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
const [selectedCompany, setSelectedCompany] = useState<string>("all");
// 모달 상태
const [formModal, setFormModal] = useState({
isOpen: false,
editingRole: null as RoleGroup | null,
});
// 권한 변경 모달
const [authEditModal, setAuthEditModal] = useState({
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
user: null as any | null,
role: null as RoleGroup | null,
});
// 회사 목록 로드 (최고 관리자만)
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
try {
const companies = await companyAPI.getList();
setCompanies(companies);
} catch (error) {
console.error("회사 목록 로드 오류:", error);
}
}, [isSuperAdmin]);
// 데이터 로드
const loadUsers = useCallback(
async (page: number = 1) => {
setIsLoading(true);
setError(null);
const loadRoleGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await userAPI.getList({
page,
size: paginationInfo.pageSize,
});
try {
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
// 회사 관리자: 자기 회사만 조회
const companyFilter =
isSuperAdmin && selectedCompany !== "all"
? selectedCompany
: isSuperAdmin
? undefined
: currentUser?.company_code;
if (response.success && response.data) {
setUsers(response.data);
setPaginationInfo({
currentPage: response.currentPage || page,
pageSize: response.pageSize || paginationInfo.pageSize,
totalItems: response.total || 0,
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
});
} else {
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("사용자 목록 로드 오류:", err);
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
const response = await roleAPI.getList({
companyCode: companyFilter,
});
if (response.success && response.data) {
setRoleGroups(response.data);
console.log("권한 그룹 조회 성공:", response.data.length, "개");
} else {
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
}
},
[paginationInfo.pageSize],
);
} catch (err) {
console.error("권한 그룹 목록 로드 오류:", err);
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [isSuperAdmin, selectedCompany, currentUser?.company_code]);
useEffect(() => {
loadUsers(1);
if (isAdmin) {
if (isSuperAdmin) {
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
}
loadRoleGroups();
} else {
setIsLoading(false);
}
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
// 권한 그룹 생성 핸들러
const handleCreateRole = useCallback(() => {
setFormModal({ isOpen: true, editingRole: null });
}, []);
// 권한 변경 핸들러
const handleEditAuth = (user: any) => {
setAuthEditModal({
isOpen: true,
user,
});
};
// 권한 그룹 수정 핸들러
const handleEditRole = useCallback((role: RoleGroup) => {
setFormModal({ isOpen: true, editingRole: role });
}, []);
// 권한 변경 모달 닫기
const handleAuthEditClose = () => {
setAuthEditModal({
isOpen: false,
user: null,
});
};
// 권한 그룹 삭제 핸들러
const handleDeleteRole = useCallback((role: RoleGroup) => {
setDeleteModal({ isOpen: true, role });
}, []);
// 권한 변경 성공
const handleAuthEditSuccess = () => {
loadUsers(paginationInfo.currentPage);
handleAuthEditClose();
};
// 폼 모달 닫기
const handleFormModalClose = useCallback(() => {
setFormModal({ isOpen: false, editingRole: null });
}, []);
// 페이지 변경
const handlePageChange = (page: number) => {
loadUsers(page);
};
// 삭제 모달 닫기
const handleDeleteModalClose = useCallback(() => {
setDeleteModal({ isOpen: false, role: null });
}, []);
// 최고 관리자가 아닌 경우
if (!isSuperAdmin) {
// 모달 성공 후 새로고침
const handleModalSuccess = useCallback(() => {
loadRoleGroups();
}, [loadRoleGroups]);
// 상세 페이지로 이동
const handleViewDetail = useCallback(
(role: RoleGroup) => {
router.push(`/admin/userMng/rolesList/${role.objid}`);
},
[router],
);
// 관리자가 아니면 접근 제한
if (!isAdmin) {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-background">
<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>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p>
</div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
@@ -131,12 +180,12 @@ export default function UserAuthPage() {
}
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-background">
<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>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p>
</div>
{/* 에러 메시지 */}
@@ -156,21 +205,154 @@ export default function UserAuthPage() {
</div>
)}
{/* 사용자 권한 테이블 */}
<UserAuthTable
users={users}
isLoading={isLoading}
paginationInfo={paginationInfo}
onEditAuth={handleEditAuth}
onPageChange={handlePageChange}
{/* 액션 버튼 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold"> ({roleGroups.length})</h2>
{/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && (
<div className="flex items-center gap-2">
<Filter className="text-muted-foreground h-4 w-4" />
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
<SelectTrigger className="h-10 w-[200px]">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCompany !== "all" && (
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<Button onClick={handleCreateRole} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 권한 그룹 목록 */}
{isLoading ? (
<div className="bg-card rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-center justify-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
) : roleGroups.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{roleGroups.map((role) => (
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
{/* 헤더 (클릭 시 상세 페이지) */}
<div
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
onClick={() => handleViewDetail(role)}
>
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{role.auth_name}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.auth_code}</p>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
role.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-muted text-foreground"
}`}
>
{role.status === "active" ? "활성" : "비활성"}
</span>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
{/* 최고 관리자는 회사명 표시 */}
{isSuperAdmin && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{companies.find((c) => c.company_code === role.company_code)?.company_name || role.company_code}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" />
</span>
<span className="font-medium">{role.member_count || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Menu className="h-3 w-3" />
</span>
<span className="font-medium">{role.menu_count || 0}</span>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t p-3">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditRole(role);
}}
className="flex-1 gap-1 text-xs"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteRole(role);
}}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 모달들 */}
<RoleFormModal
isOpen={formModal.isOpen}
onClose={handleFormModalClose}
onSuccess={handleModalSuccess}
editingRole={formModal.editingRole}
/>
{/* 권한 변경 모달 */}
<UserAuthEditModal
isOpen={authEditModal.isOpen}
onClose={handleAuthEditClose}
onSuccess={handleAuthEditSuccess}
user={authEditModal.user}
<RoleDeleteModal
isOpen={deleteModal.isOpen}
onClose={handleDeleteModalClose}
onSuccess={handleModalSuccess}
role={deleteModal.role}
/>
</div>
@@ -179,3 +361,4 @@ export default function UserAuthPage() {
</div>
);
}
@@ -25,7 +25,7 @@ interface UserAuthEditModalProps {
/**
* 사용자 권한 변경 모달
*
* 권한 레벨만 변경 가능 (최고 관리자 전용)
* 권한 레벨만 변경 가능 (관리자 이상 전용)
*/
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuthEditModalProps) {
const [selectedUserType, setSelectedUserType] = useState<string>("");
@@ -65,6 +65,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/deptMngList": dynamic(() => import("@/app/(main)/admin/userMng/deptMngList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
+7 -5
View File
@@ -850,17 +850,19 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
// 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
// expandedMenus 를 의존성에 넣으면 사용자가 수동으로 닫은 즉시 다시 펼쳐져 "닫히지 않는" 버그가 남.
const autoExpandedForTabRef = useRef<string | number | null>(null);
useEffect(() => {
if (!activeTab || uiMenus.length === 0) return;
if (autoExpandedForTabRef.current === activeTab.id) return;
autoExpandedForTabRef.current = activeTab.id;
const toExpand: string[] = [];
for (const menu of uiMenus) {
if (menu.hasChildren && menu.children) {
const hasActiveChild = menu.children.some((child: any) => isMenuActive(child));
if (hasActiveChild && !expandedMenus.has(menu.id)) {
toExpand.push(menu.id);
}
if (hasActiveChild) toExpand.push(menu.id);
}
}
if (toExpand.length > 0) {
@@ -870,7 +872,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return next;
});
}
}, [activeTab, uiMenus, isMenuActive, expandedMenus]);
}, [activeTab, uiMenus, isMenuActive]);
if (!user) {
return (
+89
View File
@@ -286,4 +286,93 @@ export const roleAPI = {
};
}
},
/**
* 권한 그룹 통합 워크스페이스 조회
* 권한 그룹 선택 시 필요한 모든 정보 한 번에 반환
* - group: 권한 그룹 정보
* - members: 권한있는 직원
* - nonMembers: 권한없는 직원
* - menus: 전체 메뉴 (트리 원천)
* - permissions: 현재 메뉴 CRUD 권한
*/
async getWorkspace(roleId: number | string): Promise<ApiResponse<{
group: any;
members: any[];
nonMembers: any[];
menus: any[];
permissions: any[];
}>> {
try {
const response = await apiClient.get(`/roles/${roleId}/workspace`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 워크스페이스 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* 개별 멤버 추가 (이미지: "<--추가" 체크 즉시 반영)
*/
async addSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
try {
const response = await apiClient.post(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 추가 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* 개별 멤버 제거 (이미지: "-->삭제" 체크 즉시 반영)
*/
async removeSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
try {
const response = await apiClient.delete(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 제거 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* 개별 메뉴 CRUD 권한 토글 (이미지: 체크 즉시 반영)
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } — 전달된 필드만 업데이트
*/
async toggleMenuPermission(
roleId: number | string,
menuObjid: number | string,
changes: {
create_yn?: "Y" | "N";
read_yn?: "Y" | "N";
update_yn?: "Y" | "N";
delete_yn?: "Y" | "N";
},
): Promise<ApiResponse<any>> {
try {
const response = await apiClient.patch(
`/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`,
changes,
);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "메뉴 권한 토글 실패",
error: error.response?.data?.error || error.message,
};
}
},
};
+35 -5
View File
@@ -8,12 +8,27 @@ export interface Department {
dept_name: string; // 부서명
company_code: string; // 회사 코드
parent_dept_code?: string | null; // 상위 부서 코드
sort_order?: number; // 정렬 순서
// dept_info 확장 컬럼
short_name?: string | null; // 부서약칭
dept_type?: string | null; // 부서유형 (dept/team/temp)
org_system?: string | null; // 조직체계
approval_manager?: string | null; // 결재관리자 user_id
dept_manager?: string | null; // 부서관리자 user_id
org_head?: string | null; // 조직장 user_id
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
status?: "active" | "inactive" | null;
created_at?: string;
updated_at?: string;
// UI용 추가 필드
children?: Department[]; // 하위 부서 목록
member_count?: number; // 부서원 수
children?: Department[];
member_count?: number;
}
// 부서원 정보
@@ -37,10 +52,25 @@ export interface UserDepartmentMapping {
created_at?: string;
}
// 부서 등록/수정 폼 데이터
// 부서 등록/수정 폼 데이터 — 기본정보 탭 모든 필드 전달 가능
export interface DepartmentFormData {
dept_name: string; // 부서명 (필수)
parent_dept_code?: string | null; // 상위 부서 코드
parent_dept_code?: string | null;
short_name?: string | null;
dept_type?: string | null;
org_system?: string | null;
approval_manager?: string | null;
dept_manager?: string | null;
org_head?: string | null;
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null;
end_date?: string | null;
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
status?: "active" | "inactive" | null;
}
// 부서 트리 노드 (UI용)