diff --git a/src/app/employees/add/page.tsx b/src/app/employees/add/page.tsx index 51debe9..c5c6868 100644 --- a/src/app/employees/add/page.tsx +++ b/src/app/employees/add/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTheme } from '@/contexts/ThemeContext'; import { ArrowLeftIcon, ShieldCheckIcon } from '@heroicons/react/24/outline'; import { useRouter } from 'next/navigation'; @@ -39,6 +39,8 @@ export default function AddEmployeePage() { phone: '', jobTitle: '', }); + const [fieldErrors, setFieldErrors] = useState<{[key: string]: string}>({}); + const errorRef = useRef(null); // 직원 스토어에서 상태와 메서드 가져오기 const { @@ -49,6 +51,13 @@ export default function AddEmployeePage() { resetRegisterSuccess } = useEmployeeStore(); + // 에러가 발생했을 때 스크롤 이동 + useEffect(() => { + if (registerError && errorRef.current) { + errorRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [registerError]); + // 전화번호 포맷팅 함수 const formatPhoneNumber = (value: string) => { const numbers = value.replace(/[^\d]/g, ''); @@ -57,20 +66,63 @@ export default function AddEmployeePage() { return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`; }; + // 필드 유효성 검사 함수 + const validateField = (name: string, value: string): string | null => { + switch (name) { + case 'name': + if (!value.trim()) { + return '이름은 필수입니다'; + } + break; + case 'email': + if (!value.trim()) { + return '이메일은 필수입니다'; + } + if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) { + return '올바른 이메일 형식이 아닙니다'; + } + break; + case 'password': + if (!value.trim()) { + return '비밀번호는 필수입니다'; + } + break; + case 'phone': + if (!value.trim()) { + return '전화번호는 필수입니다'; + } + if (!/^\d{2,3}-\d{3,4}-\d{4}$/.test(value)) { + return '전화번호 형식이 올바르지 않습니다'; + } + break; + case 'jobTitle': + if (!value.trim()) { + return '직책은 필수입니다'; + } + break; + } + return null; + }; + // 입력 필드 변경 처리 const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; + let processedValue = value; + if (name === 'phone') { - setFormData((prev) => ({ - ...prev, - [name]: formatPhoneNumber(value) - })); - } else { - setFormData((prev) => ({ - ...prev, - [name]: value - })); + processedValue = formatPhoneNumber(value); } + + const error = validateField(name, processedValue); + setFieldErrors(prev => ({ + ...prev, + [name]: error || '' + })); + + setFormData((prev) => ({ + ...prev, + [name]: processedValue + })); }; // 뒤로가기 처리 @@ -80,6 +132,24 @@ export default function AddEmployeePage() { // 탭 변경 처리 const handleTabChange = (tab: string) => { + if (tab === 'permissions') { + // 모든 필드 유효성 검사 + const errors: {[key: string]: string} = {}; + let hasErrors = false; + + Object.entries(formData).forEach(([field, value]) => { + const error = validateField(field, value); + if (error) { + errors[field] = error; + hasErrors = true; + } + }); + + if (hasErrors) { + setFieldErrors(errors); + return; + } + } setActiveTab(tab); }; @@ -111,6 +181,23 @@ export default function AddEmployeePage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // 모든 필드 유효성 검사 + const errors: {[key: string]: string} = {}; + let hasErrors = false; + + Object.entries(formData).forEach(([field, value]) => { + const error = validateField(field, value); + if (error) { + errors[field] = error; + hasErrors = true; + } + }); + + if (hasErrors) { + setFieldErrors(errors); + return; + } + // 권한 ID 목록 생성 const permissionTypes = permissions .filter(p => p.isGranted) @@ -144,7 +231,10 @@ export default function AddEmployeePage() { {/* 오류 메시지 */} {registerError && ( -
+

{registerError}

)} @@ -190,10 +280,15 @@ export default function AddEmployeePage() { value={formData.name} onChange={handleInputChange} required - className={`w-full rounded-lg border ${currentTheme.border} ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} + className={`w-full rounded-lg border ${ + fieldErrors.name ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="직원의 이름을 입력하세요" disabled={isRegistering} /> + {fieldErrors.name && ( +

{fieldErrors.name}

+ )}
{/* 이메일 */} @@ -208,10 +303,15 @@ export default function AddEmployeePage() { value={formData.email} onChange={handleInputChange} required - className={`w-full rounded-lg border ${currentTheme.border} ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} + className={`w-full rounded-lg border ${ + fieldErrors.email ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="직원의 이메일 주소를 입력하세요" disabled={isRegistering} /> + {fieldErrors.email && ( +

{fieldErrors.email}

+ )} {/* 비밀번호 */} @@ -226,16 +326,21 @@ export default function AddEmployeePage() { value={formData.password} onChange={handleInputChange} required - className={`w-full rounded-lg border ${currentTheme.border} ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} + className={`w-full rounded-lg border ${ + fieldErrors.password ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500`} placeholder="임시 비밀번호를 설정하세요" disabled={isRegistering} /> + {fieldErrors.password && ( +

{fieldErrors.password}

+ )} {/* 전화번호 */}
+ {fieldErrors.phone && ( +

{fieldErrors.phone}

+ )}
{/* 직책 */}
+ {fieldErrors.jobTitle && ( +

{fieldErrors.jobTitle}

+ )}
diff --git a/src/components/vehicles/VehicleAddModal.tsx b/src/components/vehicles/VehicleAddModal.tsx index 0bd38b7..f699ecb 100644 --- a/src/components/vehicles/VehicleAddModal.tsx +++ b/src/components/vehicles/VehicleAddModal.tsx @@ -15,7 +15,7 @@ type VehicleAddModalProps = { export default function VehicleAddModal({ isOpen, onClose, onComplete }: VehicleAddModalProps) { const { currentTheme } = useTheme(); - const { addVehicle, isLoading: storeLoading, error: storeError } = useVehicleStore(); + const { addVehicle, isLoading: storeLoading, error: storeError, checkMdnExists } = useVehicleStore(); const { fetchOverview } = useCarOverviewStore(); const [newVehicle, setNewVehicle] = useState>({ mdn: '', @@ -35,38 +35,51 @@ export default function VehicleAddModal({ isOpen, onClose, onComplete }: Vehicle const validateField = (field: keyof Omit, value: any): string | null => { switch (field) { case 'year': - const currentYear = new Date().getFullYear(); - const year = Number(value); - if (isNaN(year) || year < 1900 || year > currentYear + 1) { - return `연식은 1900년에서 ${currentYear + 1}년 사이여야 합니다`; + if (typeof value === 'string' && value.length === 0) { + return '연식을 입력해주세요'; } break; - case 'mileage': - if (value < 0 || value > 1000000) { - return '주행거리는 0km에서 1,000,000km 사이여야 합니다'; + case 'mdn': + if (typeof value === 'string' && value.length === 0) { + return '차량번호를 입력해주세요'; } break; - case 'batteryVoltage': - if (value < 0 || value > 1000) { - return '배터리 전압은 0V에서 1,000V 사이여야 합니다'; + case 'make': + if (typeof value === 'string' && value.length === 0) { + return '제조사를 입력해주세요'; + } + if (value.length > 50) { + return '제조사명은 50자를 초과할 수 없습니다'; } break; - case 'make': case 'model': - if (value.length < 1 || value.length > 50) { - return '1자에서 50자 사이로 입력해주세요'; + if (typeof value === 'string' && value.length === 0) { + return '모델명을 입력해주세요'; + } + if (value.length > 50) { + return '모델명은 50자를 초과할 수 없습니다'; + } + break; + case 'ownerType': + if (!['CORPORATE', 'PERSONAL'].includes(value)) { + return '올바른 소유구분을 선택해주세요'; + } + break; + case 'acquisitionType': + if (!['PURCHASE', 'LEASE', 'RENTAL', 'FINANCING'].includes(value)) { + return '올바른 구매방법을 선택해주세요'; } break; } return null; }; - const handleInputChange = (field: keyof Omit, value: string | number) => { + const handleInputChange = async (field: keyof Omit, value: string | number) => { let processedValue: string | number = value; - if (field === 'year' || field === 'mileage' || field === 'batteryVoltage') { - const numValue = field === 'batteryVoltage' ? parseFloat(value as string) : parseInt(value as string); - processedValue = isNaN(numValue) ? 0 : numValue; + // year는 문자열로 처리 + if (field === 'year') { + processedValue = value.toString(); } const error = validateField(field, processedValue); @@ -75,6 +88,21 @@ export default function VehicleAddModal({ isOpen, onClose, onComplete }: Vehicle [field]: error || '' })); + // mdn 중복 체크 + if (field === 'mdn' && typeof value === 'string' && value.length > 0) { + try { + const exists = await checkMdnExists(value); + if (exists) { + setFieldErrors(prev => ({ + ...prev, + [field]: '이미 존재하는 차량번호입니다' + })); + } + } catch (err) { + console.error('차량번호 중복 체크 오류:', err); + } + } + setNewVehicle({ ...newVehicle, [field]: processedValue @@ -263,49 +291,25 @@ export default function VehicleAddModal({ isOpen, onClose, onComplete }: Vehicle -
-
- - handleInputChange('year', e.target.value)} - className={`mt-1 block w-full rounded-md border ${ - fieldErrors.year ? 'border-red-500' : currentTheme.border - } ${currentTheme.inputBg} ${currentTheme.text} px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500`} - min="1900" - max={new Date().getFullYear() + 1} - required - disabled={storeLoading} - /> - {fieldErrors.year && ( -

{fieldErrors.year}

- )} -
-
- - handleInputChange('mileage', e.target.value)} - className={`mt-1 block w-full rounded-md border ${ - fieldErrors.mileage ? 'border-red-500' : currentTheme.border - } ${currentTheme.inputBg} ${currentTheme.text} px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500`} - min="0" - max="1000000" - required - disabled={storeLoading} - /> - {fieldErrors.mileage && ( -

{fieldErrors.mileage}

- )} -
+
+ + handleInputChange('year', e.target.value)} + className={`mt-1 block w-full rounded-md border ${ + fieldErrors.year ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500`} + placeholder="2024" + required + disabled={storeLoading} + /> + {fieldErrors.year && ( +

{fieldErrors.year}

+ )}
diff --git a/src/components/vehicles/VehicleDetailSlidePanel.tsx b/src/components/vehicles/VehicleDetailSlidePanel.tsx index d1859f3..98d7806 100644 --- a/src/components/vehicles/VehicleDetailSlidePanel.tsx +++ b/src/components/vehicles/VehicleDetailSlidePanel.tsx @@ -31,6 +31,7 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve timestamp: string; } | null>(null); const [isLoadingPosition, setIsLoadingPosition] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{[key: string]: string}>({}); // fetchLatestPositionData를 useCallback으로 감싸기 const fetchLatestPositionData = useCallback(async () => { @@ -84,26 +85,106 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve onClose(); }; + // 필드 유효성 검사 함수 + const validateField = (name: string, value: string): string | null => { + switch (name) { + case 'mdn': + if (!value.trim()) { + return '차량번호는 필수입니다'; + } + break; + case 'make': + if (!value.trim()) { + return '제조사는 필수입니다'; + } + if (value.length > 50) { + return '제조사는 50자를 초과할 수 없습니다'; + } + break; + case 'model': + if (!value.trim()) { + return '모델명은 필수입니다'; + } + if (value.length > 50) { + return '모델명은 50자를 초과할 수 없습니다'; + } + break; + case 'year': + if (!value.trim()) { + return '연식은 필수입니다'; + } + if (!/^\d{4}$/.test(value)) { + return '연식은 4자리 숫자여야 합니다'; + } + const yearNum = parseInt(value); + const currentYear = new Date().getFullYear(); + if (yearNum < 1900 || yearNum > currentYear) { + return `연식은 1900년부터 ${currentYear}년 사이여야 합니다`; + } + break; + } + return null; + }; + const handleInputChange = (field: keyof Vehicle, value: string | number) => { if (!editedVehicle) return; let processedValue: string | number = value; - // 숫자 필드 처리 - if (field === 'year' || field === 'mileage' || field === 'batteryVoltage') { - const numValue = field === 'batteryVoltage' ? parseFloat(value as string) : parseInt(value as string); - processedValue = isNaN(numValue) ? 0 : numValue; + // 연식 입력 시 숫자만 허용 + if (field === 'year') { + processedValue = value.toString().replace(/[^\d]/g, '').slice(0, 4); } + // 유효성 검사 + const error = validateField(field, processedValue.toString()); + setFieldErrors(prev => ({ + ...prev, + [field]: error || '' + })); + setEditedVehicle({ ...editedVehicle, [field]: processedValue }); }; + // 필수 필드가 모두 유효한지 확인하는 함수 + const isFormValid = useCallback(() => { + if (!editedVehicle) return false; + + // 필수 필드 목록 + const requiredFields: (keyof Vehicle)[] = ['mdn', 'make', 'model', 'year', 'ownerType', 'acquisitionType']; + + // 모든 필수 필드가 채워져 있고 에러가 없는지 확인 + return requiredFields.every(field => { + const value = editedVehicle[field]; + return value && !fieldErrors[field]; + }); + }, [editedVehicle, fieldErrors]); + const handleSaveEdit = async () => { if (!editedVehicle) return; + // 모든 필드 유효성 검사 + const errors: {[key: string]: string} = {}; + let hasErrors = false; + + Object.entries(editedVehicle).forEach(([field, value]) => { + if (field === 'id' || field === 'carState') return; // id와 carState 필드는 검사하지 않음 + const error = validateField(field, value.toString()); + if (error) { + errors[field] = error; + hasErrors = true; + } + }); + + if (hasErrors) { + setFieldErrors(errors); + setError('입력값을 확인해주세요'); + return; + } + try { // 낙관적 업데이트: 먼저 화면에 수정된 데이터를 표시 setIsEditing(false); @@ -244,9 +325,13 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve <> +
+ {fieldErrors.make && ( +

{fieldErrors.make}

+ )}
@@ -346,11 +437,16 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve type="text" value={editedVehicle?.model || ''} onChange={(e) => handleInputChange('model', e.target.value)} - className={`w-full rounded-md border ${currentTheme.border} ${currentTheme.inputBg} ${currentTheme.text} px-3 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-500`} + className={`w-full rounded-md border ${ + fieldErrors.model ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-3 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-500`} /> ) : displayVehicle.model}

+ {fieldErrors.model && ( +

{fieldErrors.model}

+ )}
@@ -365,11 +461,16 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve type="text" value={editedVehicle?.year || ''} onChange={(e) => handleInputChange('year', e.target.value)} - className={`w-full rounded-md border ${currentTheme.border} ${currentTheme.inputBg} ${currentTheme.text} px-3 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-500`} + className={`w-full rounded-md border ${ + fieldErrors.year ? 'border-red-500' : currentTheme.border + } ${currentTheme.inputBg} ${currentTheme.text} px-3 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-500`} /> ) : `${displayVehicle.year}년`}

+ {fieldErrors.year && ( +

{fieldErrors.year}

+ )}
@@ -404,14 +505,20 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve ) : displayVehicle.ownerType === "CORPORATE" ? "법인" : "개인"}

+ {fieldErrors.ownerType && ( +

{fieldErrors.ownerType}

+ )}
@@ -424,17 +531,22 @@ export default function VehicleDetailSlidePanel({ isOpen, onClose, vehicle }: Ve {isEditing ? ( ) : getAcquisitionTypeText(displayVehicle.acquisitionType)}

+ {fieldErrors.acquisitionType && ( +

{fieldErrors.acquisitionType}

+ )}
diff --git a/src/lib/api.ts b/src/lib/api.ts index 9a58d1d..ad784df 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -50,12 +50,12 @@ export const fetchApi = async (endpoint: string, queryParams?: Record ({})); if (responseData.message === 'ACCESS_TOKEN_EXPIRED') { - throw new Error(`토큰 만료로 인한 인증 오류: ${response.status}`); + throw new Error(`토큰 만료되었습니다.`); } else { - throw new Error(`인증 오류: ${response.status} - ${response.statusText}`); + throw new Error(`인증 오류가 발생했습니다.`); } } - throw new Error(`API 요청 실패: ${response.status} - ${response.statusText}`); + throw new Error(`요청 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`); } // 응답 크기가 0인 경우 (204 No Content 등) diff --git a/src/lib/employeeStore.ts b/src/lib/employeeStore.ts index 2b8ccba..4a0623f 100644 --- a/src/lib/employeeStore.ts +++ b/src/lib/employeeStore.ts @@ -42,7 +42,7 @@ export const useEmployeeStore = create((set) => ({ method: 'POST', body: JSON.stringify(requestData) }); - + set({ isRegistering: false, registerSuccess: true @@ -56,12 +56,12 @@ export const useEmployeeStore = create((set) => ({ registerError: error instanceof Error ? error.message : '직원 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', registerSuccess: false }); - return false; } }, resetRegisterSuccess: () => { set({ registerSuccess: false }); + localStorage.removeItem('employeeRegisterSuccess'); } })); \ No newline at end of file diff --git a/src/lib/vehicleStore.ts b/src/lib/vehicleStore.ts index 3a98286..e2982d7 100644 --- a/src/lib/vehicleStore.ts +++ b/src/lib/vehicleStore.ts @@ -27,6 +27,7 @@ interface VehicleState { updateVehicle: (vehicle: Vehicle) => Promise; deleteVehicle: (id: string) => Promise; setSelectedVehicle: (vehicle: Vehicle | null) => void; + checkMdnExists: (mdn: string) => Promise; } export const useVehicleStore = create((set, get) => ({ @@ -160,4 +161,14 @@ export const useVehicleStore = create((set, get) => ({ setSelectedVehicle: (vehicle: Vehicle | null) => { set({ selectedVehicle: vehicle }); }, + + checkMdnExists: async (mdn: string) => { + try { + const { vehicles } = get(); + return vehicles.some(vehicle => vehicle.mdn === mdn); + } catch (err) { + console.error('차량번호 중복 체크 오류:', err); + return false; + } + }, })); \ No newline at end of file