846 lines
33 KiB
TypeScript
846 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Loader2,
|
|
Settings,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Search,
|
|
RotateCcw,
|
|
Wrench,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
ResizablePanelGroup,
|
|
ResizablePanel,
|
|
ResizableHandle,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
getProcessList,
|
|
createProcess,
|
|
updateProcess,
|
|
deleteProcesses,
|
|
getProcessEquipments,
|
|
addProcessEquipment,
|
|
removeProcessEquipment,
|
|
getEquipmentList,
|
|
type ProcessMaster,
|
|
type ProcessEquipment,
|
|
type Equipment,
|
|
} from "@/lib/api/processInfo";
|
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
|
|
|
const ALL_VALUE = "__all__";
|
|
|
|
export function ProcessMasterTab() {
|
|
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
|
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
|
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
|
const [loadingList, setLoadingList] = useState(false);
|
|
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
|
|
|
const [filterCode, setFilterCode] = useState("");
|
|
const [filterName, setFilterName] = useState("");
|
|
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
|
|
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
|
|
|
|
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
|
|
|
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
|
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
|
const [addingEquipment, setAddingEquipment] = useState(false);
|
|
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
|
const [savingForm, setSavingForm] = useState(false);
|
|
const [formProcessCode, setFormProcessCode] = useState("");
|
|
const [formProcessName, setFormProcessName] = useState("");
|
|
const [formProcessType, setFormProcessType] = useState<string>("");
|
|
const [formStandardTime, setFormStandardTime] = useState("");
|
|
const [formWorkerCount, setFormWorkerCount] = useState("");
|
|
const [formUseYn, setFormUseYn] = useState("");
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const processTypeMap = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
|
|
return m;
|
|
}, [processTypeOptions]);
|
|
|
|
const getProcessTypeLabel = useCallback(
|
|
(code: string) => processTypeMap.get(code) ?? code,
|
|
[processTypeMap]
|
|
);
|
|
|
|
const loadProcesses = useCallback(async () => {
|
|
setLoadingList(true);
|
|
try {
|
|
const res = await getProcessList({
|
|
processCode: filterCode.trim() || undefined,
|
|
processName: filterName.trim() || undefined,
|
|
processType: filterType === ALL_VALUE ? undefined : filterType,
|
|
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
|
|
});
|
|
if (!res.success) {
|
|
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
|
|
return;
|
|
}
|
|
setProcesses(res.data ?? []);
|
|
} finally {
|
|
setLoadingList(false);
|
|
}
|
|
}, [filterCode, filterName, filterType, filterUseYn]);
|
|
|
|
const loadInitial = useCallback(async () => {
|
|
setLoadingInitial(true);
|
|
try {
|
|
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
|
|
if (!procRes.success) {
|
|
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
|
} else {
|
|
setProcesses(procRes.data ?? []);
|
|
}
|
|
if (!eqRes.success) {
|
|
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
|
|
} else {
|
|
setEquipmentMaster(eqRes.data ?? []);
|
|
}
|
|
const ptRes = await getCategoryValues("process_mng", "process_type");
|
|
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
|
|
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
|
|
const seen = new Set<string>();
|
|
const unique = activeValues.filter((v: any) => {
|
|
if (seen.has(v.valueCode)) return false;
|
|
seen.add(v.valueCode);
|
|
return true;
|
|
});
|
|
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
|
}
|
|
} finally {
|
|
setLoadingInitial(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadInitial();
|
|
}, [loadInitial]);
|
|
|
|
useEffect(() => {
|
|
setSelectedProcess((prev) => {
|
|
if (!prev) return prev;
|
|
if (!processes.some((p) => p.id === prev.id)) return null;
|
|
return prev;
|
|
});
|
|
}, [processes]);
|
|
|
|
useEffect(() => {
|
|
setEquipmentPick("");
|
|
}, [selectedProcess?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProcess) {
|
|
setProcessEquipments([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoadingEquipments(true);
|
|
void (async () => {
|
|
const res = await getProcessEquipments(selectedProcess.process_code);
|
|
if (cancelled) return;
|
|
if (!res.success) {
|
|
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
|
|
setProcessEquipments([]);
|
|
} else {
|
|
setProcessEquipments(res.data ?? []);
|
|
}
|
|
setLoadingEquipments(false);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedProcess?.process_code]);
|
|
|
|
const allSelected = useMemo(() => {
|
|
if (processes.length === 0) return false;
|
|
return processes.every((p) => selectedIds.has(p.id));
|
|
}, [processes, selectedIds]);
|
|
|
|
const toggleAll = (checked: boolean) => {
|
|
if (checked) {
|
|
setSelectedIds(new Set(processes.map((p) => p.id)));
|
|
} else {
|
|
setSelectedIds(new Set());
|
|
}
|
|
};
|
|
|
|
const toggleOne = (id: string, checked: boolean) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleResetFilters = () => {
|
|
setFilterCode("");
|
|
setFilterName("");
|
|
setFilterType(ALL_VALUE);
|
|
setFilterUseYn(ALL_VALUE);
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
void loadProcesses();
|
|
};
|
|
|
|
const openAdd = () => {
|
|
setFormMode("add");
|
|
setEditingId(null);
|
|
setFormProcessCode("");
|
|
setFormProcessName("");
|
|
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
|
|
setFormStandardTime("");
|
|
setFormWorkerCount("");
|
|
setFormUseYn("Y");
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const openEdit = () => {
|
|
if (!selectedProcess) {
|
|
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
|
|
return;
|
|
}
|
|
setFormMode("edit");
|
|
setEditingId(selectedProcess.id);
|
|
setFormProcessCode(selectedProcess.process_code);
|
|
setFormProcessName(selectedProcess.process_name);
|
|
setFormProcessType(selectedProcess.process_type);
|
|
setFormStandardTime(selectedProcess.standard_time ?? "");
|
|
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
|
setFormUseYn(selectedProcess.use_yn);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const submitForm = async () => {
|
|
if (!formProcessName.trim()) {
|
|
toast.error("공정명을 입력하세요.");
|
|
return;
|
|
}
|
|
|
|
setSavingForm(true);
|
|
try {
|
|
if (formMode === "add") {
|
|
const res = await createProcess({
|
|
process_name: formProcessName.trim(),
|
|
process_type: formProcessType,
|
|
standard_time: formStandardTime.trim() || "0",
|
|
worker_count: formWorkerCount.trim() || "0",
|
|
use_yn: formUseYn,
|
|
});
|
|
if (!res.success || !res.data) {
|
|
toast.error(res.message || "등록에 실패했습니다.");
|
|
return;
|
|
}
|
|
toast.success("공정이 등록되었습니다.");
|
|
setFormOpen(false);
|
|
await loadProcesses();
|
|
setSelectedProcess(res.data);
|
|
setSelectedIds(new Set());
|
|
} else if (editingId) {
|
|
const res = await updateProcess(editingId, {
|
|
process_name: formProcessName.trim(),
|
|
process_type: formProcessType,
|
|
standard_time: formStandardTime.trim() || "0",
|
|
worker_count: formWorkerCount.trim() || "0",
|
|
use_yn: formUseYn,
|
|
});
|
|
if (!res.success || !res.data) {
|
|
toast.error(res.message || "수정에 실패했습니다.");
|
|
return;
|
|
}
|
|
toast.success("공정이 수정되었습니다.");
|
|
setFormOpen(false);
|
|
await loadProcesses();
|
|
setSelectedProcess(res.data);
|
|
}
|
|
} finally {
|
|
setSavingForm(false);
|
|
}
|
|
};
|
|
|
|
const openDelete = () => {
|
|
if (selectedIds.size === 0) {
|
|
toast.message("삭제할 공정을 체크박스로 선택하세요.");
|
|
return;
|
|
}
|
|
setDeleteOpen(true);
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
const ids = Array.from(selectedIds);
|
|
setDeleting(true);
|
|
try {
|
|
const res = await deleteProcesses(ids);
|
|
if (!res.success) {
|
|
toast.error(res.message || "삭제에 실패했습니다.");
|
|
return;
|
|
}
|
|
toast.success(`${ids.length}건 삭제되었습니다.`);
|
|
setDeleteOpen(false);
|
|
setSelectedIds(new Set());
|
|
if (selectedProcess && ids.includes(selectedProcess.id)) {
|
|
setSelectedProcess(null);
|
|
}
|
|
await loadProcesses();
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const availableEquipments = useMemo(() => {
|
|
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
|
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
|
}, [equipmentMaster, processEquipments]);
|
|
|
|
const handleAddEquipment = async () => {
|
|
if (!selectedProcess) return;
|
|
if (!equipmentPick) {
|
|
toast.message("추가할 설비를 선택하세요.");
|
|
return;
|
|
}
|
|
setAddingEquipment(true);
|
|
try {
|
|
const res = await addProcessEquipment({
|
|
process_code: selectedProcess.process_code,
|
|
equipment_code: equipmentPick,
|
|
});
|
|
if (!res.success) {
|
|
toast.error(res.message || "설비 추가에 실패했습니다.");
|
|
return;
|
|
}
|
|
toast.success("설비가 등록되었습니다.");
|
|
setEquipmentPick("");
|
|
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
|
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
|
} finally {
|
|
setAddingEquipment(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveEquipment = async (row: ProcessEquipment) => {
|
|
const res = await removeProcessEquipment(row.id);
|
|
if (!res.success) {
|
|
toast.error(res.message || "설비 제거에 실패했습니다.");
|
|
return;
|
|
}
|
|
toast.success("설비가 제거되었습니다.");
|
|
if (selectedProcess) {
|
|
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
|
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
|
}
|
|
};
|
|
|
|
const listBusy = loadingInitial || loadingList;
|
|
|
|
return (
|
|
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
|
<ResizablePanel defaultSize={50} minSize={30}>
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
|
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
|
|
<span className="text-sm font-semibold sm:text-base">공정 마스터</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs sm:text-sm">공정코드</Label>
|
|
<Input
|
|
value={filterCode}
|
|
onChange={(e) => setFilterCode(e.target.value)}
|
|
placeholder="코드"
|
|
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs sm:text-sm">공정명</Label>
|
|
<Input
|
|
value={filterName}
|
|
onChange={(e) => setFilterName(e.target.value)}
|
|
placeholder="이름"
|
|
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs sm:text-sm">공정유형</Label>
|
|
<Select value={filterType} onValueChange={setFilterType}>
|
|
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
|
전체
|
|
</SelectItem>
|
|
{processTypeOptions.map((o, idx) => (
|
|
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
|
{o.valueLabel}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs sm:text-sm">사용여부</Label>
|
|
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
|
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
|
전체
|
|
</SelectItem>
|
|
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
|
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={handleResetFilters}
|
|
>
|
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
|
초기화
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={handleSearch}
|
|
disabled={listBusy}
|
|
>
|
|
<Search className="mr-1 h-3.5 w-3.5" />
|
|
조회
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={openAdd}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
공정 추가
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={openEdit}
|
|
>
|
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
|
수정
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={openDelete}
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
삭제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="p-2 sm:p-3">
|
|
{listBusy ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<Loader2 className="h-8 w-8 animate-spin" />
|
|
<p className="mt-2 text-xs sm:text-sm">불러오는 중...</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10 text-center">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
onCheckedChange={(v) => toggleAll(v === true)}
|
|
aria-label="전체 선택"
|
|
className="mx-auto"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="text-xs sm:text-sm">공정코드</TableHead>
|
|
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
|
<TableHead className="text-xs sm:text-sm">공정유형</TableHead>
|
|
<TableHead className="text-right text-xs sm:text-sm">표준시간(분)</TableHead>
|
|
<TableHead className="text-right text-xs sm:text-sm">작업인원</TableHead>
|
|
<TableHead className="text-center text-xs sm:text-sm">사용여부</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{processes.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
|
<p className="text-xs sm:text-sm">조회된 공정이 없습니다.</p>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
processes.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
"cursor-pointer transition-colors",
|
|
selectedProcess?.id === row.id && "bg-accent"
|
|
)}
|
|
onClick={() => setSelectedProcess(row)}
|
|
>
|
|
<TableCell
|
|
className="text-center"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Checkbox
|
|
checked={selectedIds.has(row.id)}
|
|
onCheckedChange={(v) => toggleOne(row.id, v === true)}
|
|
aria-label={`${row.process_code} 선택`}
|
|
className="mx-auto"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-xs font-medium sm:text-sm">
|
|
{row.process_code}
|
|
</TableCell>
|
|
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
|
|
<TableCell className="text-xs sm:text-sm">
|
|
<Badge variant="secondary" className="text-[10px] sm:text-xs">
|
|
{getProcessTypeLabel(row.process_type)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right text-xs sm:text-sm">
|
|
{row.standard_time ?? "-"}
|
|
</TableCell>
|
|
<TableCell className="text-right text-xs sm:text-sm">
|
|
{row.worker_count ?? "-"}
|
|
</TableCell>
|
|
<TableCell className="text-center text-xs sm:text-sm">
|
|
<Badge
|
|
variant={row.use_yn === "N" ? "outline" : "default"}
|
|
className="text-[10px] sm:text-xs"
|
|
>
|
|
{row.use_yn === "Y" ? "사용" : "미사용"}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
<ResizablePanel defaultSize={50} minSize={30}>
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
|
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
|
|
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold sm:text-base">공정별 사용설비</p>
|
|
{selectedProcess ? (
|
|
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
|
{selectedProcess.process_name}{" "}
|
|
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground sm:text-sm">공정 미선택</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!selectedProcess ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
|
|
<Settings className="h-10 w-10 opacity-40" />
|
|
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택하세요</p>
|
|
<p className="max-w-xs text-xs sm:text-sm">
|
|
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
|
|
<Label className="text-xs sm:text-sm">설비 선택</Label>
|
|
<Select
|
|
key={selectedProcess.id}
|
|
value={equipmentPick || undefined}
|
|
onValueChange={setEquipmentPick}
|
|
disabled={addingEquipment || availableEquipments.length === 0}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="설비를 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableEquipments.map((eq) => (
|
|
<SelectItem
|
|
key={eq.id}
|
|
value={eq.equipment_code}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
{eq.equipment_code} · {eq.equipment_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
onClick={() => void handleAddEquipment()}
|
|
disabled={addingEquipment || !equipmentPick}
|
|
>
|
|
{addingEquipment ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1">
|
|
{loadingEquipments ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="h-7 w-7 animate-spin" />
|
|
<p className="mt-2 text-xs sm:text-sm">설비 목록 불러오는 중...</p>
|
|
</div>
|
|
) : processEquipments.length === 0 ? (
|
|
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
|
|
등록된 설비가 없습니다. 상단에서 설비를 추가하세요.
|
|
</p>
|
|
) : (
|
|
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
|
|
<ul className="space-y-2">
|
|
{processEquipments.map((pe) => (
|
|
<li key={pe.id}>
|
|
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
|
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-medium sm:text-sm">
|
|
{pe.equipment_code}
|
|
</p>
|
|
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
|
{pe.equipment_name || "설비명 없음"}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
|
onClick={() => void handleRemoveEquipment(pe)}
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
제거
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
|
|
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{formMode === "add" ? "공정 추가" : "공정 수정"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
|
|
공정명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="pm-process-name"
|
|
value={formProcessName}
|
|
onChange={(e) => setFormProcessName(e.target.value)}
|
|
placeholder="공정명"
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">공정유형</Label>
|
|
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{processTypeOptions.map((o, idx) => (
|
|
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
|
{o.valueLabel}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
|
|
표준작업시간(분)
|
|
</Label>
|
|
<Input
|
|
id="pm-standard-time"
|
|
value={formStandardTime}
|
|
onChange={(e) => setFormStandardTime(e.target.value)}
|
|
placeholder="0"
|
|
inputMode="numeric"
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
|
|
작업인원수
|
|
</Label>
|
|
<Input
|
|
id="pm-worker-count"
|
|
value={formWorkerCount}
|
|
onChange={(e) => setFormWorkerCount(e.target.value)}
|
|
placeholder="0"
|
|
inputMode="numeric"
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">사용여부</Label>
|
|
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
|
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setFormOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
disabled={savingForm}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void submitForm()}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
disabled={savingForm}
|
|
>
|
|
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">공정 삭제</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은
|
|
되돌릴 수 없습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setDeleteOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
disabled={deleting}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => void confirmDelete()}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
삭제
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|