Implement batch equipment addition with modal in ProcessMasterTab
- Replaced the `SmartSelect` component with a `FullscreenDialog` for improved user experience. - Introduced multi-selection capability for equipment addition, allowing users to select multiple items at once. - Added search functionality within the modal to filter available equipment by name or code. - Updated the backend call to `addProcessEquipmentBatch` to handle multiple equipment codes. - Ensured proper state management for selected equipment and search input, enhancing the overall workflow. (TASK: ERP-node-110)
This commit is contained in:
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,29 +341,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -521,32 +556,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -679,6 +695,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -89,8 +90,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -191,7 +195,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,30 +363,59 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
// equipment_code가 비어있으면 id를 저장 (백엔드 JOIN이 양쪽 다 매칭)
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
// equipment_code가 비어있으면 id를 저장 (백엔드 JOIN이 양쪽 다 매칭)
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -716,32 +752,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -906,6 +923,108 @@ export function ProcessMasterTab() {
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -336,29 +342,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -522,32 +557,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -680,6 +696,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,29 +341,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -521,32 +556,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -679,6 +695,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,29 +341,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -521,32 +556,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -679,6 +695,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
@@ -81,10 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
// 다중 선택 일괄 추가 (TASK:ERP-node-087)
|
||||
// 다중 선택 일괄 추가 (TASK:ERP-node-087) + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -173,9 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
// 공정 전환 시 다중선택/검색 초기화
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -389,6 +392,7 @@ export function ProcessMasterTab() {
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -552,82 +556,14 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
설비 선택 (다중)
|
||||
</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="max-h-[240px] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-xs">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
@@ -759,6 +695,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -336,29 +342,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -522,32 +557,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -680,6 +696,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
addProcessEquipmentBatch,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
@@ -82,8 +82,11 @@ export function ProcessMasterTab() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
// 다중 선택 일괄 추가 + 모달화 (TASK:ERP-node-110)
|
||||
const [equipmentPicks, setEquipmentPicks] = useState<Set<string>>(() => new Set());
|
||||
const [equipmentSearch, setEquipmentSearch] = useState("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
@@ -172,7 +175,10 @@ export function ProcessMasterTab() {
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
// 공정 전환 시 다중선택/검색/모달 초기화
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -335,29 +341,58 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
// 검색어로 거른 추가 가능 설비 목록
|
||||
const filteredAvailableEquipments = useMemo(() => {
|
||||
const q = equipmentSearch.trim().toLowerCase();
|
||||
if (!q) return availableEquipments;
|
||||
return availableEquipments.filter(
|
||||
(e) =>
|
||||
(e.equipment_name || "").toLowerCase().includes(q) ||
|
||||
(e.equipment_code || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableEquipments, equipmentSearch]);
|
||||
|
||||
const toggleEquipmentPick = (id: string) => {
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEquipments = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
if (equipmentPicks.size === 0) {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
const codes = availableEquipments
|
||||
.filter((e) => equipmentPicks.has(e.id))
|
||||
.map((e) => e.equipment_code || e.id);
|
||||
if (codes.length === 0) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
const res = await addProcessEquipmentBatch({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
equipment_codes: codes,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었어요");
|
||||
setEquipmentPick("");
|
||||
const d = res.data;
|
||||
toast.success(
|
||||
d
|
||||
? `설비 ${d.inserted}개 등록${d.skipped > 0 ? ` (${d.skipped}개는 이미 등록되어 제외)` : ""}`
|
||||
: "설비가 등록되었어요"
|
||||
);
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
setEquipmentModalOpen(false);
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
@@ -521,32 +556,13 @@ export function ProcessMasterTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
placeholder="설비를 선택해주세요"
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="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" />
|
||||
)}
|
||||
추가
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
등록된 설비{processEquipments.length > 0 ? ` (${processEquipments.length})` : ""}
|
||||
</Label>
|
||||
<Button size="sm" onClick={() => setEquipmentModalOpen(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
설비 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -679,6 +695,108 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정별 사용설비 추가 모달 (TASK:ERP-node-110) */}
|
||||
<FullscreenDialog
|
||||
open={equipmentModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
setEquipmentModalOpen(v);
|
||||
if (!v) {
|
||||
setEquipmentPicks(new Set());
|
||||
setEquipmentSearch("");
|
||||
}
|
||||
}}
|
||||
title="공정별 사용설비 추가"
|
||||
description={
|
||||
selectedProcess
|
||||
? `${selectedProcess.process_name} (${selectedProcess.process_code})`
|
||||
: undefined
|
||||
}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEquipmentModalOpen(false)}
|
||||
disabled={addingEquipment}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAddEquipments()}
|
||||
disabled={addingEquipment || equipmentPicks.size === 0}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
선택 추가{equipmentPicks.size > 0 ? ` (${equipmentPicks.size})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={equipmentSearch}
|
||||
onChange={(e) => setEquipmentSearch(e.target.value)}
|
||||
placeholder="설비명/코드 검색"
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{availableEquipments.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
추가 가능한 설비가 없어요 (모두 등록됨)
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="sticky top-0 z-10 flex cursor-pointer items-center gap-2 border-b bg-background px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredAvailableEquipments.length > 0 &&
|
||||
filteredAvailableEquipments.every((e) => equipmentPicks.has(e.id))
|
||||
}
|
||||
onCheckedChange={(c) =>
|
||||
setEquipmentPicks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) filteredAvailableEquipments.forEach((e) => next.add(e.id));
|
||||
else filteredAvailableEquipments.forEach((e) => next.delete(e.id));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="font-medium">전체 선택</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{equipmentPicks.size}/{availableEquipments.length} 선택
|
||||
</span>
|
||||
</label>
|
||||
{filteredAvailableEquipments.map((eq) => (
|
||||
<label
|
||||
key={eq.id}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={equipmentPicks.has(eq.id)}
|
||||
onCheckedChange={() => toggleEquipmentPick(eq.id)}
|
||||
/>
|
||||
<span className="truncate">{eq.equipment_name}</span>
|
||||
{eq.equipment_code && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{eq.equipment_code}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredAvailableEquipments.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없어요
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user