Files
pipeline/frontend/app/(main)/COMPANY_16/master-data/company/page.tsx
T
DDD1542 f179a575ab feat: add shipping plan page with search and detail editing functionality
- Implemented the ShippingPlanPage component for managing shipment plans.
- Added search filters for date range, status, customer, and keywords.
- Integrated table for displaying shipment plans with grouping and selection features.
- Included detail panel for editing plan quantity, date, and memo with validation.
- Enhanced table readability with CSS adjustments for cell padding and hover effects.

style: improve global styles for table readability

- Adjusted padding and font sizes for table cells and headers.
- Added striped background for even rows and hover effects for better visibility.

fix: update TableSettingsModal for better overflow handling

- Modified modal layout to ensure proper scrolling for content overflow.
- Ensured drag-and-drop functionality for column settings remains intact.

chore: register new routes for COMPANY_7 and COMPANY_16 features

- Added dynamic imports for new pages related to purchase, logistics, quality, and design for COMPANY_7 and COMPANY_16.
2026-04-03 09:28:43 +09:00

868 lines
40 KiB
TypeScript

"use client";
/**
* 회사관리 — Type D 탭 멀티뷰 (2탭)
*
* Tab 1: 회사정보 (company_mng 단일 레코드 폼)
* Tab 2: 부서관리 (dept_info 트리 + user_info 목록)
*/
import React, { useState, useEffect, useCallback, useRef } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const COMPANY_TABLE = "company_mng";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
/* ── 트리 노드 타입 ── */
interface DeptNode {
dept_code: string;
dept_name: string;
parent_dept_code: string | null;
status?: string;
children: DeptNode[];
}
export default function CompanyPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
/* ===================== Tab 1: 회사정보 ===================== */
const [companyData, setCompanyData] = useState<Record<string, any>>({});
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
const [companyLoading, setCompanyLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
// 이미지 업로드 refs
const imageRef = useRef<HTMLInputElement>(null);
const logoRef = useRef<HTMLInputElement>(null);
const sealRef = useRef<HTMLInputElement>(null);
// 이미지 미리보기
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [sealPreview, setSealPreview] = useState<string | null>(null);
const fetchCompany = useCallback(async () => {
setCompanyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
page: 1, size: 1, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
if (rows.length > 0) {
setCompanyData(rows[0]);
setCompanyForm(rows[0]);
if (rows[0].company_image) setImagePreview(rows[0].company_image);
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
}
} catch {
toast.error("회사 정보를 불러오는데 실패했어요.");
} finally {
setCompanyLoading(false);
}
}, []);
useEffect(() => { fetchCompany(); }, [fetchCompany]);
const handleImageUpload = (
e: React.ChangeEvent<HTMLInputElement>,
field: string,
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreview(result);
setCompanyForm((prev) => ({ ...prev, [field]: result }));
};
reader.readAsDataURL(file);
};
const handleCompanySave = async () => {
if (!companyForm.company_name) {
toast.error("회사명은 필수예요.");
return;
}
setSaving(true);
try {
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
if (companyData.company_code) {
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
originalData: { company_code: companyData.company_code },
updatedData,
});
} else {
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
}
toast.success("회사 정보가 저장되었어요.");
setEditMode(false);
fetchCompany();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setCompanyForm(companyData);
setImagePreview(companyData.company_image || null);
setLogoPreview(companyData.company_logo || null);
setSealPreview(companyData.company_seal || null);
setEditMode(false);
};
/* ===================== Tab 2: 부서관리 ===================== */
const [depts, setDepts] = useState<any[]>([]);
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
// 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [deptSaving, setDeptSaving] = useState(false);
// 채번
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 트리 구성
const buildTree = (flatDepts: any[]): DeptNode[] => {
const map: Record<string, DeptNode> = {};
const roots: DeptNode[] = [];
flatDepts.forEach((d) => {
map[d.dept_code] = { ...d, children: [] };
});
flatDepts.forEach((d) => {
const node = map[d.dept_code];
if (d.parent_dept_code && map[d.parent_dept_code]) {
map[d.parent_dept_code].children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setDepts(raw);
setDeptTree(buildTree(raw));
// 전부 펼치기
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
} catch {
toast.error("부서 목록을 불러오는데 실패했어요.");
} finally {
setDeptLoading(false);
}
}, []);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
// 사원 조회
const fetchMembers = useCallback(async () => {
if (!selectedDeptCode) { setMembers([]); return; }
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 트리 토글
const toggleExpand = (code: string) => {
setExpandedDepts((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code); else next.add(code);
return next;
});
};
// 부서 등록
const openDeptRegister = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch { /* 채번 규칙 없으면 무시 */ }
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수예요."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setDeptSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했어요."); return; }
toast.success("수정되었어요.");
} else {
const companyCode = user?.companyCode || "";
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else { toast.error("채번 코드 할당에 실패했어요."); return; }
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) { toast.error((response as any).error || "등록에 실패했어요."); return; }
toast.success("등록되었어요.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제할까요?", {
description: "해당 부서에 소속된 사원 정보는 유지돼요.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했어요."); return; }
toast.success((response as any).message || "삭제되었어요.");
setSelectedDeptCode(null);
fetchDepts();
} catch { toast.error("삭제에 실패했어요."); }
};
// 사원 추가/수정
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수예요."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수예요."); return; }
if (!userForm.dept_code) { toast.error("부서는 필수예요."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setDeptSaving(true);
try {
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined),
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었어요." : "사원이 추가되었어요.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setDeptSaving(false);
}
};
/* ── 트리 렌더 ── */
const renderTree = (nodes: DeptNode[], depth = 0) => {
return nodes.map((node) => {
const isExpanded = expandedDepts.has(node.dept_code);
const isSelected = selectedDeptCode === node.dept_code;
const hasChildren = node.children.length > 0;
return (
<div key={node.dept_code}>
<div
className={cn(
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
!isSelected && "border-l-2 border-transparent",
)}
style={{ paddingLeft: `${12 + depth * 20}px` }}
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
>
{hasChildren ? (
<button
className="p-0.5 rounded hover:bg-accent"
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
>
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="w-4.5" />
)}
{isExpanded && hasChildren
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
: <Folder className="w-4 h-4 text-muted-foreground" />
}
<span className="truncate">{node.dept_name}</span>
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0"></Badge>}
</div>
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
</div>
);
});
};
/* ── 이미지 업로드 박스 ── */
const ImageUploadBox = ({
label, preview, inputRef, field, setPreview,
}: {
label: string;
preview: string | null;
inputRef: React.RefObject<HTMLInputElement | null>;
field: string;
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
}) => (
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{preview ? (
<>
<img src={preview} alt={label} className="w-full h-full object-contain" />
{editMode && (
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
setPreview(null);
setCompanyForm((prev) => ({ ...prev, [field]: null }));
}}>
<X className="w-3 h-3" />
</Button>
</div>
)}
</>
) : (
<button
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => editMode && inputRef.current?.click()}
disabled={!editMode}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageUpload(e, field, setPreview)}
/>
</div>
);
return (
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
{/* 탭 컨테이너 */}
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
{/* 탭 헤더 — border-b 스타일 */}
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="company"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Building2 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger
value="department"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4 gap-1.5"
>
<Users className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</div>
{/* ===================== Tab 1: 회사정보 ===================== */}
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
<div className="border rounded-lg bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span> </span>
</div>
<div className="flex gap-1.5">
{editMode ? (
<>
<Button variant="outline" size="sm" onClick={cancelEdit}></Button>
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
</Button>
</>
) : (
<Button size="sm" onClick={() => setEditMode(true)}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{companyLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="p-6 space-y-6">
{/* 기본 정보 섹션 제목 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
{/* 기본 정보 그리드 (2열) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={companyForm.company_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
placeholder="회사명을 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.business_registration_number || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
placeholder="000-00-00000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_name || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
placeholder="대표자명"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.representative_phone || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
placeholder="02-0000-0000"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.fax || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
placeholder="02-0000-0001"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.email || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
placeholder="example@company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.website || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
placeholder="https://www.company.com"
className="h-9" disabled={!editMode}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={companyForm.address || ""}
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
placeholder="회사 주소를 입력해주세요"
className="h-9" disabled={!editMode}
/>
</div>
</div>
{/* 이미지 섹션 */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-6">
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
</div>
</div>
)}
</div>
</TabsContent>
{/* ===================== Tab 2: 부서관리 ===================== */}
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
<div className="h-full overflow-hidden border rounded-none bg-card">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 트리 */}
<ResizablePanel defaultSize={30} minSize={20}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4 text-muted-foreground" />
<span></span>
<Badge variant="secondary" className="font-mono text-xs">{depts.length}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" className="h-8" onClick={openDeptRegister}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{deptLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : deptTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Building2 className="w-8 h-8 mb-2" />
<span className="text-sm"> </span>
</div>
) : (
renderTree(deptTree)
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 목록 */}
<ResizablePanel defaultSize={70} minSize={40}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}</Badge>}
</div>
{selectedDeptCode && (
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{selectedDeptCode ? (
<div className="flex-1 overflow-auto">
{memberLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Users className="w-8 h-8 mb-2" />
<span className="text-sm"> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-muted z-10">
<TableRow>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((row) => (
<TableRow
key={row.user_id || row.id}
className="cursor-pointer"
onDoubleClick={() => openUserModal(row)}
>
<TableCell className="text-[13px]">{row.sabun || "-"}</TableCell>
<TableCell className="text-sm font-medium">{row.user_name}</TableCell>
<TableCell className="text-[13px] font-mono">{row.user_id}</TableCell>
<TableCell className="text-[13px]">{row.position_name || "-"}</TableCell>
<TableCell className="text-[13px]">{row.cell_phone || "-"}</TableCell>
<TableCell className="text-[13px]">{row.email || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Users className="w-10 h-10 mb-3" />
<span className="text-sm"> </span>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</TabsContent>
</Tabs>
{/* ── 부서 등록/수정 모달 ── */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정해요." : "새로운 부서를 등록해요."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
className="h-9" disabled readOnly
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input
value={deptForm.dept_name || ""}
onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 사원 추가/수정 모달 ── */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>
{userEditMode
? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정해요.`
: selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가해요.` : "사원을 추가해요."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" autoComplete="off" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input type="date" value={userForm.end_date || ""} onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))} className="h-9" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={deptSaving}>
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}