2a23cadb41
- 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.
760 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|