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:
kjs
2026-05-26 10:06:51 +09:00
parent 851b247a5e
commit 9bdd3bb668
8 changed files with 1209 additions and 344 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}