feat: [품질관리] 수주선택 모달 발주처별 비활성화 제약 추가

- SearchableSelectionModal에 isItemDisabled 콜백 prop 추가 (공통)
  - renderItem에 isDisabled 3번째 파라미터 전달 (하위호환)
  - disabled 아이템 클릭 차단 + opacity/cursor 스타일 적용
  - 전체선택 시 disabled 아이템 제외
- OrderSelectModal: 선택된 발주처와 다른 발주처의 수주 비활성화
  - 이미 선택된 아이템은 해제 가능 (disabled 예외)
This commit is contained in:
2026-03-05 23:15:17 +09:00
parent 899493a74d
commit fe930b5831
3 changed files with 54 additions and 15 deletions

View File

@@ -38,6 +38,7 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
listWrapper,
infoText,
mode,
isItemDisabled,
} = props;
const {
@@ -88,15 +89,20 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
});
}, []);
// 전체선택 토글
// 전체선택 토글 (비활성 아이템 제외)
const handleToggleAll = useCallback(() => {
const targetItems = isItemDisabled
? items.filter((item) => !isItemDisabled(item, items.filter((i) => selectedIds.has(keyExtractor(i)))))
: items;
setSelectedIds((prev) => {
if (prev.size === items.length) {
const targetIds = targetItems.map((item) => keyExtractor(item));
const allSelected = targetIds.every((id) => prev.has(id));
if (allSelected) {
return new Set();
}
return new Set(items.map((item) => keyExtractor(item)));
return new Set(targetIds);
});
}, [items, keyExtractor]);
}, [items, keyExtractor, isItemDisabled, selectedIds]);
// 다중선택 확인
const handleConfirm = useCallback(() => {
@@ -107,16 +113,34 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
}
}, [mode, items, selectedIds, keyExtractor, props, onOpenChange]);
// 선택된 아이템 목록 (isItemDisabled 콜백용)
const selectedItems = useCallback(() => {
return items.filter((item) => selectedIds.has(keyExtractor(item)));
}, [items, selectedIds, keyExtractor]);
// 비활성 판정
const checkDisabled = useCallback((item: T) => {
if (!isItemDisabled) return false;
// 이미 선택된 아이템은 disabled가 아님 (해제 가능해야 함)
if (selectedIds.has(keyExtractor(item))) return false;
return isItemDisabled(item, selectedItems());
}, [isItemDisabled, selectedIds, keyExtractor, selectedItems]);
// 클릭 핸들러: 모드에 따라 분기
const handleItemClick = useCallback((item: T) => {
if (checkDisabled(item)) return;
if (mode === 'single') {
handleSingleSelect(item);
} else {
handleToggle(keyExtractor(item));
}
}, [mode, handleSingleSelect, handleToggle, keyExtractor]);
}, [mode, handleSingleSelect, handleToggle, keyExtractor, checkDisabled]);
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
// 전체선택 (비활성 아이템 제외)
const enabledItems = isItemDisabled
? items.filter((item) => !checkDisabled(item))
: items;
const isAllSelected = enabledItems.length > 0 && enabledItems.every((item) => selectedIds.has(keyExtractor(item)));
const isSelected = (item: T) => selectedIds.has(keyExtractor(item));
// 빈 상태 메시지 결정
@@ -158,7 +182,8 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
const itemElements = items.map((item) => {
const key = keyExtractor(item);
const rendered = renderItem(item, isSelected(item));
const disabled = checkDisabled(item);
const rendered = renderItem(item, isSelected(item), disabled);
// renderItem이 유효한 React 엘리먼트를 반환하면 key와 onClick을 직접 주입 (div 래핑 없이)
// 이렇게 하면 <TableRow> 등 테이블 요소를 <div>로 감싸는 HTML 유효성 에러를 방지
@@ -166,20 +191,27 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
return cloneElement(rendered as React.ReactElement<Record<string, unknown>>, {
key,
onClick: (e: React.MouseEvent) => {
// 기존 onClick이 있으면 먼저 호출
if (disabled) return;
const existingOnClick = (rendered.props as Record<string, unknown>)?.onClick;
if (typeof existingOnClick === 'function') {
(existingOnClick as (e: React.MouseEvent) => void)(e);
}
handleItemClick(item);
},
className: `cursor-pointer ${(rendered.props as Record<string, unknown>)?.className || ''}`.trim(),
className: [
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
(rendered.props as Record<string, unknown>)?.className || '',
].filter(Boolean).join(' '),
});
}
// 일반 텍스트/fragment인 경우 기존 div 래핑 유지
return (
<div key={key} onClick={() => handleItemClick(item)} className="cursor-pointer">
<div
key={key}
onClick={disabled ? undefined : () => handleItemClick(item)}
className={disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}
>
{rendered}
</div>
);

View File

@@ -17,8 +17,10 @@ interface BaseProps<T> {
fetchData: (query: string) => Promise<T[]>;
/** 고유 키 추출 */
keyExtractor: (item: T) => string;
/** 아이템 렌더링 */
renderItem: (item: T, isSelected: boolean) => ReactNode;
/** 아이템 렌더링 (isDisabled: 비활성 상태) */
renderItem: (item: T, isSelected: boolean, isDisabled?: boolean) => ReactNode;
/** 아이템 비활성 조건 (선택된 아이템 목록 기반) */
isItemDisabled?: (item: T, selectedItems: T[]) => boolean;
// 검색 설정
/** 검색 모드: debounce(자동) vs enter(수동) */

View File

@@ -70,6 +70,11 @@ export function OrderSelectModal({
onSelect={onSelect}
confirmLabel="선택"
allowSelectAll
isItemDisabled={(item, selectedItems) => {
if (selectedItems.length === 0) return false;
const selectedClient = selectedItems[0].clientName;
return item.clientName !== selectedClient;
}}
listWrapper={(children, selectState) => (
<Table>
<TableHeader>
@@ -95,10 +100,10 @@ export function OrderSelectModal({
</TableBody>
</Table>
)}
renderItem={(item, isSelected) => (
<TableRow className="cursor-pointer hover:bg-muted/50">
renderItem={(item, isSelected, isDisabled) => (
<TableRow className={isDisabled ? '' : 'hover:bg-muted/50'}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} />
<Checkbox checked={isSelected} disabled={isDisabled} />
</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>