fix(WEB): 폼 컴포넌트 DatePicker 적용 및 코드 정리

- ExpectedExpenseManagement DatePicker 적용 및 간소화
- BoardForm 날짜 필드 개선
- AttendanceInfoDialog, ReasonInfoDialog 코드 정리
- ReceivingDetail 기능 보강
- ShipmentCreate/Edit DatePicker 적용
- VehicleDispatchEdit 수정
- WorkOrderCreate 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-06 16:46:41 +09:00
parent 666eb6bcc6
commit 5344bfc426
9 changed files with 104 additions and 207 deletions

View File

@@ -42,8 +42,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { DatePicker } from '@/components/ui/date-picker';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -142,7 +141,7 @@ export function ExpectedExpenseManagement({
// 예상 지급일 변경 다이얼로그
const [showDateChangeDialog, setShowDateChangeDialog] = useState(false);
const [newExpectedDate, setNewExpectedDate] = useState<Date | undefined>(undefined);
const [newExpectedDate, setNewExpectedDate] = useState('');
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -166,7 +165,6 @@ export function ExpectedExpenseManagement({
paymentStatus: 'pending',
note: '',
});
const [formExpectedDate, setFormExpectedDate] = useState<Date | undefined>(new Date());
// 거래처/계좌 옵션
const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([]);
@@ -207,7 +205,6 @@ export function ExpectedExpenseManagement({
paymentStatus: 'pending',
note: '',
});
setFormExpectedDate(new Date());
setEditingItem(null);
}, []);
@@ -232,7 +229,6 @@ export function ExpectedExpenseManagement({
paymentStatus: item.paymentStatus,
note: item.note,
});
setFormExpectedDate(item.expectedPaymentDate ? new Date(item.expectedPaymentDate) : new Date());
setShowFormDialog(true);
}, []);
@@ -520,22 +516,21 @@ export function ExpectedExpenseManagement({
// ===== 예상 지급일 변경 핸들러 =====
const handleOpenDateChangeDialog = useCallback(() => {
if (selectedItems.size === 0) return;
setNewExpectedDate(undefined);
setNewExpectedDate('');
setShowDateChangeDialog(true);
}, [selectedItems.size]);
const handleConfirmDateChange = useCallback(async () => {
if (!newExpectedDate || selectedItems.size === 0) return;
const newDateStr = format(newExpectedDate, 'yyyy-MM-dd');
const selectedIds = Array.from(selectedItems);
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newDateStr);
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
if (result.success) {
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newDateStr }
? { ...item, expectedPaymentDate: newExpectedDate }
: item
));
toast.success(`${result.updatedCount || selectedIds.length}건의 예상 지급일이 변경되었습니다.`);
@@ -544,7 +539,7 @@ export function ExpectedExpenseManagement({
toast.error(result.error || '예상 지급일 변경에 실패했습니다.');
}
setShowDateChangeDialog(false);
setNewExpectedDate(undefined);
setNewExpectedDate('');
});
}, [newExpectedDate, selectedItems]);
@@ -1063,24 +1058,11 @@ export function ExpectedExpenseManagement({
{/* 예상 지급일 선택 */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newExpectedDate ? format(newExpectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={newExpectedDate}
onSelect={setNewExpectedDate}
/>
</PopoverContent>
</Popover>
<DatePicker
value={newExpectedDate}
onChange={setNewExpectedDate}
className="w-full"
/>
</div>
</div>
@@ -1113,29 +1095,11 @@ export function ExpectedExpenseManagement({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formExpectedDate ? format(formExpectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formExpectedDate}
onSelect={(date) => {
setFormExpectedDate(date);
if (date) {
setFormData(prev => ({ ...prev, expectedPaymentDate: format(date, 'yyyy-MM-dd') }));
}
}}
/>
</PopoverContent>
</Popover>
<DatePicker
value={formData.expectedPaymentDate}
onChange={(date) => setFormData(prev => ({ ...prev, expectedPaymentDate: date }))}
className="w-full"
/>
</div>
{/* 거래유형 */}

View File

@@ -166,8 +166,8 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
}, [boardCode, title, content]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async () => {
if (!validate()) return;
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!validate()) return { success: false, error: '' };
setIsSubmitting(true);
try {
@@ -186,20 +186,19 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
result = await updatePost(boardCode, initialData.id, postData);
}
if (result?.success) {
toast.success(mode === 'create' ? '게시글이 등록되었습니다.' : '게시글이 수정되었습니다.');
router.push('/ko/board');
} else {
toast.error(result?.error || '게시글 저장에 실패했습니다.');
if (!result?.success) {
return { success: false, error: result?.error || '게시글 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('게시글 저장 오류:', error);
toast.error('게시글 저장에 실패했습니다.');
const errorMessage = error instanceof Error ? error.message : '게시글 저장에 실패했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [boardCode, title, content, isPinned, mode, initialData, router, validate]);
}, [boardCode, title, content, isPinned, mode, initialData, validate]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
@@ -418,9 +417,8 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
mode={mode}
isLoading={isBoardsLoading}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>

View File

@@ -17,16 +17,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CalendarIcon } from 'lucide-react';
import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import type {
AttendanceInfoDialogProps,
AttendanceFormData,
@@ -59,7 +51,6 @@ export function AttendanceInfoDialog({
onSave,
}: AttendanceInfoDialogProps) {
const [formData, setFormData] = useState<AttendanceFormData>(initialFormData);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
// 모드별 타이틀
const title = mode === 'create' ? '근태 정보' : '근태 정보';
@@ -82,10 +73,8 @@ export function AttendanceInfoDialog({
weekendOvertimeHours: '0',
weekendOvertimeMinutes: '0',
});
setSelectedDate(new Date(attendance.baseDate));
} else if (open && mode === 'create') {
setFormData(initialFormData);
setSelectedDate(new Date());
}
}, [open, attendance, mode]);
@@ -94,14 +83,6 @@ export function AttendanceInfoDialog({
setFormData(prev => ({ ...prev, [field]: value }));
};
// 날짜 변경 핸들러
const handleDateChange = (date: Date | undefined) => {
setSelectedDate(date);
if (date) {
setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') }));
}
};
// 저장
const handleSubmit = () => {
onSave(formData);
@@ -142,29 +123,12 @@ export function AttendanceInfoDialog({
{/* 기준일 */}
<div className="flex items-center justify-between">
<Label className="text-sm font-medium min-w-[80px]"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[200px] justify-start text-left font-normal',
!selectedDate && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateChange}
locale={ko}
initialFocus
/>
</PopoverContent>
</Popover>
<DatePicker
value={formData.baseDate}
onChange={(date) => handleChange('baseDate', date)}
className="w-[200px]"
align="end"
/>
</div>
{/* 출근 시간 */}

View File

@@ -17,16 +17,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CalendarIcon } from 'lucide-react';
import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import type {
ReasonInfoDialogProps,
ReasonFormData,
@@ -47,13 +39,11 @@ export function ReasonInfoDialog({
onSubmit,
}: ReasonInfoDialogProps) {
const [formData, setFormData] = useState<ReasonFormData>(initialFormData);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
// 데이터 초기화
useEffect(() => {
if (open) {
setFormData(initialFormData);
setSelectedDate(new Date());
}
}, [open]);
@@ -62,14 +52,6 @@ export function ReasonInfoDialog({
setFormData(prev => ({ ...prev, [field]: value }));
};
// 날짜 변경 핸들러
const handleDateChange = (date: Date | undefined) => {
setSelectedDate(date);
if (date) {
setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') }));
}
};
// 등록 (문서 작성 화면으로 이동)
const handleSubmit = () => {
onSubmit(formData);
@@ -107,29 +89,12 @@ export function ReasonInfoDialog({
{/* 기준일 */}
<div className="flex items-center justify-between">
<Label className="text-sm font-medium min-w-[80px]"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[200px] justify-start text-left font-normal',
!selectedDate && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateChange}
locale={ko}
initialFocus
/>
</PopoverContent>
</Popover>
<DatePicker
value={formData.baseDate}
onChange={(date) => handleChange('baseDate', date)}
className="w-[200px]"
align="end"
/>
</div>
{/* 유형 선택 */}

View File

@@ -227,31 +227,41 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
}));
};
// 저장 핸들러
const handleSave = async () => {
// 저장 핸들러 - IntegratedDetailTemplate의 onSubmit에서 호출
// 반환값으로 성공/실패를 전달하여 템플릿이 toast/navigation 처리
const handleSave = async (): Promise<{ success: boolean; error?: string }> => {
// 클라이언트 사이드 필수 필드 검증
const errors: string[] = [];
if (!formData.itemCode) errors.push('품목코드');
if (!formData.supplier) errors.push('발주처');
if (!formData.receivingQty) errors.push('입고수량');
if (!formData.receivingDate) errors.push('입고일');
if (errors.length > 0) {
return { success: false, error: `필수 항목을 입력해주세요: ${errors.join(', ')}` };
}
setIsSaving(true);
try {
if (isNewMode) {
const result = await createReceiving(formData);
if (result.success) {
toast.success('입고가 등록되었습니다.');
router.push('/ko/material/receiving-management');
} else {
toast.error(result.error || '등록에 실패했습니다.');
if (!result.success) {
return { success: false, error: result.error || '등록에 실패했습니다.' };
}
return { success: true };
} else if (isEditMode) {
const result = await updateReceiving(id, formData);
if (result.success) {
toast.success('입고 정보가 수정되었습니다.');
router.push(`/ko/material/receiving-management/${id}?mode=view`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
if (!result.success) {
return { success: false, error: result.error || '수정에 실패했습니다.' };
}
return { success: true };
}
return { success: false, error: '알 수 없는 모드입니다.' };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ReceivingDetail] handleSave error:', err);
toast.error('저장 중 오류가 발생했습니다.');
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSaving(false);
}
@@ -754,8 +764,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
renderView={() => renderViewContent()}
renderForm={() => renderFormContent()}
onSubmit={async () => {
await handleSave();
return { success: true };
return await handleSave();
}}
onCancel={handleCancel}
/>

View File

@@ -296,25 +296,25 @@ export function ShipmentCreate() {
return errors.length === 0;
};
const handleSubmit = useCallback(async () => {
if (!validateForm()) return;
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!validateForm()) return { success: false, error: '' };
setIsSubmitting(true);
try {
const result = await createShipment(formData);
if (result.success) {
router.push('/ko/outbound/shipments');
} else {
setValidationErrors([result.error || '출고 등록에 실패했습니다.']);
if (!result.success) {
return { success: false, error: result.error || '출고 등록에 실패했습니다.' };
}
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentCreate] handleSubmit error:', err);
setValidationErrors(['저장 중 오류가 발생했습니다.']);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
}, [formData]);
// 제품 부품 테이블 렌더링
const renderPartsTable = (parts: ProductPart[]) => (
@@ -762,9 +762,8 @@ export function ShipmentCreate() {
mode="create"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>

View File

@@ -295,25 +295,25 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
return errors.length === 0;
};
const handleSubmit = useCallback(async () => {
if (!validateForm()) return;
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!validateForm()) return { success: false, error: '' };
setIsSubmitting(true);
try {
const result = await updateShipment(id, formData);
if (result.success) {
router.push(`/ko/outbound/shipments/${id}?mode=view`);
} else {
setValidationErrors([result.error || '출고 수정에 실패했습니다.']);
if (!result.success) {
return { success: false, error: result.error || '출고 수정에 실패했습니다.' };
}
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentEdit] handleSubmit error:', err);
setValidationErrors(['저장 중 오류가 발생했습니다.']);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [id, formData, router]);
}, [id, formData]);
// 제품 부품 테이블 렌더링
const renderPartsTable = (parts: ProductPart[]) => (
@@ -781,9 +781,8 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
mode="edit"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>

View File

@@ -21,6 +21,7 @@ import {
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vehicleDispatchEditConfig } from './vehicleDispatchConfig';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getVehicleDispatchById, updateVehicleDispatch } from './actions';
import {
VEHICLE_DISPATCH_STATUS_LABELS,
@@ -135,22 +136,23 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
}, [router, id]);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
setIsSubmitting(true);
try {
const result = await updateVehicleDispatch(id, formData);
if (result.success) {
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
} else {
setValidationErrors([result.error || '배차차량 수정에 실패했습니다.']);
if (!result.success) {
return { success: false, error: result.error || '배차차량 수정에 실패했습니다.' };
}
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[VehicleDispatchEdit] handleSubmit error:', err);
setValidationErrors(['저장 중 오류가 발생했습니다.']);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [id, formData, router]);
}, [id, formData]);
// 동적 config
const dynamicConfig = {
@@ -387,9 +389,8 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
mode="edit"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>

View File

@@ -170,7 +170,7 @@ export function WorkOrderCreate() {
};
// 폼 제출
const handleSubmit = async () => {
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
@@ -200,7 +200,7 @@ export function WorkOrderCreate() {
setValidationErrors(errors);
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
return { success: false, error: '' };
}
// 에러 초기화
@@ -218,16 +218,15 @@ export function WorkOrderCreate() {
note: formData.note || undefined,
});
if (result.success) {
toast.success('작업지시 등록되었습니다.');
router.push('/production/work-orders');
} else {
toast.error(result.error || '작업지시 등록에 실패했습니다.');
if (!result.success) {
return { success: false, error: result.error || '작업지시 등록에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderCreate] handleSubmit error:', error);
toast.error('작업지시 등록 중 오류가 발생했습니다.');
const errorMessage = error instanceof Error ? error.message : '작업지시 등록 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
@@ -518,9 +517,8 @@ export function WorkOrderCreate() {
mode="create"
isLoading={isLoadingProcesses}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>