feat(WEB): DatePicker 공통화 및 공정관리/작업자화면 대폭 개선

DatePicker 공통화:
- date-picker.tsx 공통 컴포넌트 신규 추가
- 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일)
- DateRangeSelector 개선

공정관리:
- RuleModal 대폭 리팩토링 (-592줄 → 간소화)
- ProcessForm, StepForm 개선
- ProcessDetail 수정, actions 확장

작업자화면:
- WorkerScreen 기능 대폭 확장 (+543줄)
- WorkItemCard 개선
- types 확장

회계/인사/영업/품질:
- BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용
- EmployeeForm, VacationDialog 등 DatePicker 적용
- OrderRegistration, QuoteRegistration DatePicker 적용
- InspectionCreate, InspectionDetail DatePicker 적용

공사관리/CEO대시보드:
- BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용
- ScheduleDetailModal, TodayIssueSection 개선

기타:
- WorkOrderCreate/Edit/Detail/List 개선
- ShipmentCreate/Edit, ReceivingDetail 개선
- calendar, calendarEvents 수정
- datepicker 마이그레이션 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-06 15:48:00 +09:00
parent e453753bdd
commit c2ed71540f
68 changed files with 1436 additions and 1134 deletions

View File

@@ -11,10 +11,6 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { QuantityInput } from '@/components/ui/quantity-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
@@ -31,17 +27,33 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Package } from 'lucide-react';
import type {
ClassificationRule,
RuleRegistrationType,
RuleType,
MatchingType,
} from '@/types/process';
import { RULE_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
import { Search } from 'lucide-react';
import type { ClassificationRule } from '@/types/process';
import { PROCESS_CATEGORY_OPTIONS } from '@/types/process';
// 품목 유형 기본 옵션 (전체)
const DEFAULT_ITEM_TYPE_OPTION = { value: 'all', label: '전체' };
// 공정 필터 옵션
const PROCESS_FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '스크린', label: '스크린' },
{ value: '슬릿', label: '슬릿' },
{ value: '절곡', label: '절곡' },
];
// 공정 필터에 따른 구분 필터 옵션
function getCategoryFilterOptions(processFilter: string): { value: string; label: string }[] {
if (processFilter === 'all') {
return [{ value: 'all', label: '전체' }];
}
const categories = PROCESS_CATEGORY_OPTIONS[processFilter];
if (!categories || categories.length === 0) {
return [{ value: 'all', label: '전체' }];
}
// 스크린의 경우 '없음'만 있으므로 전체만 표시
if (categories.length === 1 && categories[0].value === '없음') {
return [{ value: 'all', label: '전체' }];
}
return [{ value: 'all', label: '전체' }, ...categories];
}
interface RuleModalProps {
open: boolean;
@@ -50,44 +62,40 @@ interface RuleModalProps {
editRule?: ClassificationRule;
/** 현재 공정 ID (다른 공정에 이미 배정된 품목 제외용) */
processId?: string;
/** 현재 공정명 (하단 안내 문구용) */
processName?: string;
}
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: RuleModalProps) {
// 공통 상태
const [registrationType, setRegistrationType] = useState<RuleRegistrationType>(
editRule?.registrationType || 'pattern'
);
const [description, setDescription] = useState(editRule?.description || '');
// 패턴 규칙용 상태
const [ruleType, setRuleType] = useState<RuleType>(editRule?.ruleType || '품목코드');
const [matchingType, setMatchingType] = useState<MatchingType>(
editRule?.matchingType || 'startsWith'
);
const [conditionValue, setConditionValue] = useState(editRule?.conditionValue || '');
const [priority, setPriority] = useState(editRule?.priority || 10);
const [isActive, setIsActive] = useState(editRule?.isActive ?? true);
// 개별 품목용 상태
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName }: RuleModalProps) {
// 검색/필터 상태
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedItemType, setSelectedItemType] = useState('all');
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
// 공정/구분 필터 상태
const [processFilter, setProcessFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
// 품목 목록 API 상태
const [itemList, setItemList] = useState<ItemOption[]>([]);
const [isItemsLoading, setIsItemsLoading] = useState(false);
// 품목 유형 옵션 (common_codes에서 동적 조회)
const [itemTypeOptions, setItemTypeOptions] = useState<Array<{ value: string; label: string }>>([DEFAULT_ITEM_TYPE_OPTION]);
const [itemTypeOptions, setItemTypeOptions] = useState<Array<{ value: string; label: string }>>([
{ value: 'all', label: '전체' },
]);
// 품목 목록 로드 (debounced)
// 구분 필터 옵션 (공정 필터에 따라 변경)
const categoryFilterOptions = getCategoryFilterOptions(processFilter);
// 품목 목록 로드
const loadItems = useCallback(async (q?: string, itemType?: string) => {
setIsItemsLoading(true);
const items = await getItemList({
q: q || undefined,
itemType: itemType === 'all' ? undefined : itemType,
size: 1000, // 전체 품목 조회
excludeProcessId: processId, // 다른 공정에 이미 배정된 품목 제외
size: 1000,
excludeProcessId: processId,
});
setItemList(items);
setIsItemsLoading(false);
@@ -96,21 +104,14 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
// 검색어 유효성 검사 함수
const isValidSearchKeyword = (keyword: string): boolean => {
if (!keyword || keyword.trim() === '') return false;
const trimmed = keyword.trim();
// 한글이 포함되어 있으면 1자 이상
const hasKorean = /[가-힣]/.test(trimmed);
if (hasKorean) return trimmed.length >= 1;
// 영어/숫자만 있으면 2자 이상
return trimmed.length >= 2;
};
// 검색어/품목유형 변경 시 API 호출 (debounce)
// 검색어 변경 시 API 호출 (debounce)
useEffect(() => {
if (registrationType !== 'individual') return;
// 검색어 유효성 검사 - 유효하지 않으면 빈 목록
if (!isValidSearchKeyword(searchKeyword)) {
setItemList([]);
return;
@@ -121,30 +122,40 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
}, 300);
return () => clearTimeout(timer);
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
}, [searchKeyword, selectedItemType, loadItems]);
// 품목유형 변경 시 검색어가 유효하면 재검색
useEffect(() => {
if (registrationType !== 'individual') return;
if (!isValidSearchKeyword(searchKeyword)) return;
loadItems(searchKeyword, selectedItemType);
}, [selectedItemType]);
// 모달 열릴 때 품목 목록 초기화 + 품목유형 옵션 로드
// 모달 열릴 때 초기화
useEffect(() => {
if (open) {
// 품목유형 옵션 로드 (common_codes에서 동적 조회)
// 품목유형 옵션 로드
getItemTypeOptions().then((options) => {
setItemTypeOptions([DEFAULT_ITEM_TYPE_OPTION, ...options]);
setItemTypeOptions([{ value: 'all', label: '전체' }, ...options]);
});
if (registrationType === 'individual') {
setItemList([]);
setSearchKeyword('');
if (editRule) {
// 수정 모드: 기존 선택된 품목 ID 설정
if (editRule.registrationType === 'individual') {
const ids = editRule.conditionValue.split(',').filter(Boolean);
setSelectedItemIds(new Set(ids));
} else {
setSelectedItemIds(new Set());
}
} else {
setSelectedItemIds(new Set());
}
setSearchKeyword('');
setSelectedItemType('all');
setProcessFilter('all');
setCategoryFilter('all');
setItemList([]);
}
}, [open, registrationType]);
}, [open, editRule]);
// 공정 필터 변경 시 구분 필터 리셋
useEffect(() => {
setCategoryFilter('all');
}, [processFilter]);
// 체크박스 토글
const handleToggleItem = (id: string) => {
@@ -159,363 +170,174 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
});
};
// 전체 선택
const handleSelectAll = () => {
const allIds = itemList.map((item) => item.id);
setSelectedItemIds(new Set(allIds));
};
// 초기화
const handleResetSelection = () => {
setSelectedItemIds(new Set());
};
// 모달 열릴 때 초기화 또는 수정 데이터 로드
useEffect(() => {
if (open) {
if (editRule) {
// 수정 모드: 기존 데이터 로드
setRegistrationType(editRule.registrationType);
setDescription(editRule.description || '');
setRuleType(editRule.ruleType);
setMatchingType(editRule.matchingType);
setConditionValue(editRule.conditionValue);
setPriority(editRule.priority);
setIsActive(editRule.isActive);
setSearchKeyword('');
setSelectedItemType('all');
// 개별 품목인 경우 선택된 품목 ID 설정
if (editRule.registrationType === 'individual') {
const ids = editRule.conditionValue.split(',').filter(Boolean);
setSelectedItemIds(new Set(ids));
} else {
setSelectedItemIds(new Set());
}
} else {
// 추가 모드: 초기화 (개별 품목을 디폴트로)
setRegistrationType('individual');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setIsActive(true);
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemIds(new Set());
}
}
}, [open, editRule]);
// 저장
const handleSubmit = () => {
if (registrationType === 'pattern') {
if (!conditionValue.trim()) {
alert('조건 값을 입력해주세요.');
return;
}
} else {
if (selectedItemIds.size === 0) {
alert('품목을 최소 1개 이상 선택해주세요.');
return;
}
if (selectedItemIds.size === 0) {
alert('품목을 최소 1개 이상 선택해주세요.');
return;
}
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
const finalConditionValue =
registrationType === 'individual'
? Array.from(selectedItemIds).join(',')
: conditionValue.trim();
const finalConditionValue = Array.from(selectedItemIds).join(',');
onAdd({
registrationType,
ruleType: registrationType === 'individual' ? '품목코드' : ruleType,
matchingType: registrationType === 'individual' ? 'equals' : matchingType,
registrationType: 'individual',
ruleType: '품목코드',
matchingType: 'equals',
conditionValue: finalConditionValue,
priority: registrationType === 'individual' ? 10 : priority,
description: description.trim() || undefined,
isActive: registrationType === 'individual' ? true : isActive,
priority: 10,
description: undefined,
isActive: true,
});
// Reset form
setRegistrationType('pattern');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setIsActive(true);
// Reset
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemIds(new Set());
setProcessFilter('all');
setCategoryFilter('all');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={registrationType === 'individual' ? 'max-w-xl max-h-[90vh] flex flex-col' : 'max-w-md'}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{editRule ? '규칙 수정' : '규칙 추가'}</DialogTitle>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1">
{/* 등록 방식 */}
<div className="space-y-3">
<Label> *</Label>
<RadioGroup
value={registrationType}
onValueChange={(v) => setRegistrationType(v as RuleRegistrationType)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pattern" id="pattern" />
<Label htmlFor="pattern" className="font-normal">
(/ )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="font-normal">
( )
</Label>
</div>
</RadioGroup>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="품목코드 또는 품목명으로 검색..."
className="pl-9"
/>
</div>
{/* 패턴 규칙 UI */}
{registrationType === 'pattern' && (
<>
{/* 규칙 유형 */}
<div className="space-y-2">
<Label> *</Label>
<Select value={ruleType} onValueChange={(v) => setRuleType(v as RuleType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RULE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 카운트 + 필터 행 */}
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isItemsLoading ? (
'로딩 중...'
) : (
<>
{itemList.length}{' '}
{selectedItemIds.size > 0 && (
<span className="text-primary font-medium">
{selectedItemIds.size}
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-2">
<Select value={processFilter} onValueChange={setProcessFilter}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder="공정" />
</SelectTrigger>
<SelectContent>
{PROCESS_FILTER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
key={`category-${processFilter}`}
value={categoryFilter}
onValueChange={setCategoryFilter}
disabled={categoryFilterOptions.length <= 1}
>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{categoryFilterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 매칭 방식 */}
<div className="space-y-2">
<Label> *</Label>
<Select
value={matchingType}
onValueChange={(v) => setMatchingType(v as MatchingType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MATCHING_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조건 값 */}
<div className="space-y-2">
<Label> *</Label>
<div className="flex gap-2">
<Input
value={conditionValue}
onChange={(e) => setConditionValue(e.target.value)}
placeholder="예: SCR-, E-, STEEL-"
className="flex-1"
/>
<Button variant="outline" size="icon">
<Search className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enter
</p>
</div>
{/* 우선순위 - 패턴 규칙에서만 표시 */}
<div className="space-y-2">
<Label></Label>
<QuantityInput
value={priority}
onChange={(value) => setPriority(value ?? 1)}
min={1}
max={100}
className="w-24"
/>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 설명 - 패턴 규칙 */}
<div className="space-y-2">
<Label></Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="규칙에 대한 설명"
/>
</div>
{/* 활성 상태 - 패턴 규칙에서만 표시 */}
<div className="flex items-center justify-between">
<Label> </Label>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
</>
)}
{/* 개별 품목 UI - 기획서 기준 */}
{registrationType === 'individual' && (
<>
{/* 설명 (선택) */}
<div className="space-y-2">
<Label> ()</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 품목 그룹에 대한 설명"
/>
</div>
{/* 품목 검색 + 품목 유형 필터 */}
<div className="flex gap-3">
<div className="flex-1">
<Label className="text-sm"> </Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="품목코드 또는 품목명으로 검색..."
className="pl-9"
/>
</div>
</div>
<div className="w-32">
<Label className="text-sm"> </Label>
<Select value={selectedItemType} onValueChange={setSelectedItemType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{itemTypeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 품목 목록 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<Package className="h-4 w-4" />
<span>
{isItemsLoading ? (
'로딩 중...'
) : (
<> ({itemList.length}) | ({selectedItemIds.size})</>
)}
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="text-xs h-7"
disabled={isItemsLoading || itemList.length === 0}
>
</Button>
<span className="text-muted-foreground">|</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetSelection}
className="text-xs h-7"
disabled={selectedItemIds.size === 0}
>
</Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg max-h-[280px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
{/* 품목 테이블 */}
<div className="border rounded-lg max-h-[340px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{/* TODO: API에서 process_name, process_category 필드 지원 후 실제 데이터 표시 */}
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isItemsLoading ? (
<TableRow key="loading">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
...
</TableCell>
</TableRow>
) : itemList.length === 0 ? (
<TableRow key="empty">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
{searchKeyword.trim() === ''
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
: '검색 결과가 없습니다'}
</TableCell>
</TableRow>
) : (
itemList.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleToggleItem(item.id)}
>
<TableCell>
<Checkbox
checked={selectedItemIds.has(item.id)}
onCheckedChange={() => handleToggleItem(item.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell className="font-medium">{item.code}</TableCell>
<TableCell>{item.fullName}</TableCell>
{/* TODO: API 지원 후 item.processName / item.processCategory 표시 */}
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell className="text-muted-foreground">-</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{isItemsLoading ? (
<TableRow key="loading">
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
...
</TableCell>
</TableRow>
) : itemList.length === 0 ? (
<TableRow key="empty">
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
{searchKeyword.trim() === ''
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
: '검색 결과가 없습니다'}
</TableCell>
</TableRow>
) : (
itemList.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleToggleItem(item.id)}
>
<TableCell>
<Checkbox
checked={selectedItemIds.has(item.id)}
onCheckedChange={() => handleToggleItem(item.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">{item.code}</TableCell>
<TableCell>{item.fullName}</TableCell>
<TableCell>{item.type}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))
)}
</TableBody>
</Table>
</div>
{/* 안내 문구 */}
<p className="text-xs text-muted-foreground">
. .
</p>
</>
)}
{/* 안내 문구 */}
<p className="text-xs text-muted-foreground">
{' '}
<span className="font-medium text-foreground">
{processName || '해당'}
</span>{' '}
.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}>{editRule ? '수정' : '추가'}</Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>