feat(WEB): 공정관리 품목 제거 기능 및 리팩토링
- ProcessDetail: 개별 품목 제거(removeProcessItem) 기능 추가 - ProcessDetail: onProcessUpdate 콜백으로 부모 컴포넌트 동기화 - ProcessDetail: 삭제 다이얼로그 제거, 품목 목록 flatMap 추출 방식 개선 - ProcessForm: 규칙 모달 관련 코드 추가 - RuleModal: UI 개선 - actions.ts: removeProcessItem API 함수 추가
This commit is contained in:
@@ -20,15 +20,15 @@ import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { getProcessSteps, reorderProcessSteps, deleteProcess } from './actions';
|
||||
import { getProcessSteps, reorderProcessSteps, removeProcessItem } from './actions';
|
||||
import type { Process, ProcessStep } from '@/types/process';
|
||||
|
||||
interface ProcessDetailProps {
|
||||
process: Process;
|
||||
onProcessUpdate?: (process: Process) => void;
|
||||
}
|
||||
|
||||
export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const { canUpdate } = usePermission();
|
||||
@@ -37,19 +37,16 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
const [steps, setSteps] = useState<ProcessStep[]>([]);
|
||||
const [isStepsLoading, setIsStepsLoading] = useState(true);
|
||||
|
||||
// 삭제 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 드래그 상태
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const dragNodeRef = useRef<HTMLTableRowElement | null>(null);
|
||||
|
||||
// 품목 개수 계산 (기존 classificationRules에서 individual 품목)
|
||||
const itemCount = process.classificationRules
|
||||
// 개별 품목 목록 추출
|
||||
const individualItems = process.classificationRules
|
||||
.filter((r) => r.registrationType === 'individual')
|
||||
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
|
||||
.flatMap((r) => r.items || []);
|
||||
const itemCount = individualItems.length;
|
||||
|
||||
// 단계 목록 로드
|
||||
useEffect(() => {
|
||||
@@ -64,6 +61,21 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
loadSteps();
|
||||
}, [process.id]);
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = async (itemId: string) => {
|
||||
const remainingIds = individualItems
|
||||
.filter((item) => item.id !== itemId)
|
||||
.map((item) => parseInt(item.id, 10));
|
||||
|
||||
const result = await removeProcessItem(process.id, remainingIds);
|
||||
if (result.success && result.data) {
|
||||
toast.success('품목이 제거되었습니다.');
|
||||
onProcessUpdate?.(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '품목 제거에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 네비게이션
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/master-data/process-management/${process.id}?mode=edit`);
|
||||
@@ -81,24 +93,6 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
router.push(`/ko/master-data/process-management/${process.id}/steps/${stepId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteProcess(process.id);
|
||||
if (result.success) {
|
||||
toast.success('공정이 삭제되었습니다.');
|
||||
router.push('/ko/master-data/process-management');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 드래그&드롭 (HTML5 네이티브) =====
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||||
setDragIndex(index);
|
||||
@@ -225,6 +219,28 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{individualItems.length > 0 && (
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{individualItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground shrink-0">{item.code}</span>
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="shrink-0 ml-auto rounded-sm text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 단계 테이블 */}
|
||||
@@ -367,27 +383,12 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<span className="hidden md:inline">목록으로</span>
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)} size="sm" className="md:size-default">
|
||||
<Trash2 className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">삭제</span>
|
||||
</Button>
|
||||
<Button onClick={handleEdit} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleEdit} size="sm" className="md:size-default">
|
||||
<Edit className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 공정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function ProcessDetailClientV2({ processId, initialMode }: ProcessDetailC
|
||||
|
||||
// 상세 보기 모드
|
||||
if (mode === 'view' && processData) {
|
||||
return <ProcessDetail process={processData} />;
|
||||
return <ProcessDetail process={processData} onProcessUpdate={setProcessData} />;
|
||||
}
|
||||
|
||||
// 데이터 없음 (should not reach here)
|
||||
|
||||
@@ -106,10 +106,23 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
}
|
||||
}, [categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 품목 개수 계산
|
||||
const itemCount = classificationRules
|
||||
// 개별 품목 목록 추출
|
||||
const individualItems = classificationRules
|
||||
.filter((r) => r.registrationType === 'individual')
|
||||
.reduce((sum, r) => sum + (r.items?.length || 0), 0);
|
||||
.flatMap((r) => r.items || []);
|
||||
const itemCount = individualItems.length;
|
||||
|
||||
// 품목 삭제 (로컬 상태에서 제거)
|
||||
const handleRemoveItem = useCallback((itemId: string) => {
|
||||
setClassificationRules((prev) =>
|
||||
prev.map((rule) => {
|
||||
if (rule.registrationType !== 'individual') return rule;
|
||||
const filtered = (rule.items || []).filter((item) => item.id !== itemId);
|
||||
const newCondition = filtered.map((item) => item.id).join(',');
|
||||
return { ...rule, items: filtered, conditionValue: newCondition };
|
||||
}).filter((rule) => rule.registrationType !== 'individual' || (rule.items && rule.items.length > 0))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 부서 목록 + 단계 목록 로드
|
||||
useEffect(() => {
|
||||
@@ -136,13 +149,51 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
}
|
||||
}, [isEdit, initialData?.id]);
|
||||
|
||||
// 품목 규칙 추가/수정
|
||||
// 이미 등록된 품목 ID 목록 (RuleModal에서 필터링용)
|
||||
const registeredItemIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
classificationRules
|
||||
.filter((r) => r.registrationType === 'individual')
|
||||
.forEach((r) => {
|
||||
// API에서 로드된 items
|
||||
(r.items || []).forEach((item) => ids.add(item.id));
|
||||
// conditionValue (새로 선택된 것 포함)
|
||||
r.conditionValue.split(',').filter(Boolean).forEach((id) => ids.add(id));
|
||||
});
|
||||
return ids;
|
||||
}, [classificationRules]);
|
||||
|
||||
// 품목 규칙 추가/수정 (기존 individual 규칙과 병합하여 중복 방지)
|
||||
const handleSaveRule = useCallback(
|
||||
(ruleData: Omit<ClassificationRule, 'id' | 'createdAt'>) => {
|
||||
if (editingRule) {
|
||||
setClassificationRules((prev) =>
|
||||
prev.map((r) => (r.id === editingRule.id ? { ...r, ...ruleData } : r))
|
||||
);
|
||||
} else if (ruleData.registrationType === 'individual') {
|
||||
// 새로 선택된 품목 ID
|
||||
const newItemIds = ruleData.conditionValue.split(',').filter(Boolean);
|
||||
|
||||
setClassificationRules((prev) => {
|
||||
const existingIndividualRule = prev.find((r) => r.registrationType === 'individual');
|
||||
if (existingIndividualRule) {
|
||||
// 기존 individual 규칙에 병합 (중복 제거)
|
||||
const existingIds = existingIndividualRule.conditionValue.split(',').filter(Boolean);
|
||||
const mergedIds = [...new Set([...existingIds, ...newItemIds])];
|
||||
return prev.map((r) =>
|
||||
r.id === existingIndividualRule.id
|
||||
? { ...r, conditionValue: mergedIds.join(',') }
|
||||
: r
|
||||
);
|
||||
} else {
|
||||
// 새 규칙 생성
|
||||
return [...prev, {
|
||||
...ruleData,
|
||||
id: `rule-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
}];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const newRule: ClassificationRule = {
|
||||
...ruleData,
|
||||
@@ -443,6 +494,28 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{individualItems.length > 0 && (
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{individualItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground shrink-0">{item.code}</span>
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="shrink-0 ml-auto rounded-sm text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 단계 테이블 */}
|
||||
@@ -593,6 +666,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
editRule={editingRule}
|
||||
processId={initialData?.id}
|
||||
processName={processName}
|
||||
registeredItemIds={registeredItemIds}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -613,6 +687,9 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
departmentOptions,
|
||||
isDepartmentsLoading,
|
||||
itemCount,
|
||||
individualItems,
|
||||
registeredItemIds,
|
||||
handleRemoveItem,
|
||||
dragIndex,
|
||||
dragOverIndex,
|
||||
handleSaveRule,
|
||||
|
||||
@@ -64,9 +64,11 @@ interface RuleModalProps {
|
||||
processId?: string;
|
||||
/** 현재 공정명 (하단 안내 문구용) */
|
||||
processName?: string;
|
||||
/** 이미 등록된 품목 ID 목록 (검색 결과에서 제외) */
|
||||
registeredItemIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName }: RuleModalProps) {
|
||||
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName, registeredItemIds }: RuleModalProps) {
|
||||
// 검색/필터 상태
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [selectedItemType, setSelectedItemType] = useState('all');
|
||||
@@ -97,9 +99,13 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc
|
||||
size: 1000,
|
||||
excludeProcessId: processId,
|
||||
});
|
||||
setItemList(items);
|
||||
// 이미 등록된 품목 필터링
|
||||
const filtered = registeredItemIds && registeredItemIds.size > 0
|
||||
? items.filter((item) => !registeredItemIds.has(item.id))
|
||||
: items;
|
||||
setItemList(filtered);
|
||||
setIsItemsLoading(false);
|
||||
}, [processId]);
|
||||
}, [processId, registeredItemIds]);
|
||||
|
||||
// 검색어 유효성 검사 함수
|
||||
const isValidSearchKeyword = (keyword: string): boolean => {
|
||||
|
||||
@@ -279,6 +279,21 @@ export async function updateProcess(id: string, data: ProcessFormData): Promise<
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 품목 제거 (item_ids만 업데이트)
|
||||
*/
|
||||
export async function removeProcessItem(processId: string, remainingItemIds: number[]): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/processes/${processId}`,
|
||||
method: 'PUT',
|
||||
body: { item_ids: remainingItemIds },
|
||||
transform: (d: ApiProcess) => transformApiToFrontend(d),
|
||||
errorMessage: '품목 제거에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 삭제
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user