Files
pipeline/frontend/app/(main)/COMPANY_10/master-data/company/page.tsx
T
kjs 2a23cadb41 feat: Enhance user management and reporting features
- Added `end_date` field to user management for better tracking of user status.
- Updated SQL queries in `adminController` to include `end_date` during user save operations.
- Improved purchase report data handling by refining the logic for received quantities.
- Enhanced file preview functionality to streamline file path handling.
- Updated outbound and receiving controllers to ensure accurate updates to shipment and purchase order details.

These changes aim to improve the overall functionality and user experience in managing user data and reporting processes.
2026-04-08 15:33:09 +09:00

760 lines
34 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";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
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);
}
};
// EDataTable 컬럼 정의 (사원 목록)
const companyMemberColumns: EDataTableColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
{ key: "email", label: "이메일" },
];
/* ── 트리 렌더 ── */
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>
</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>
</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>
);
}