diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts index 61a8a010..a448d9c0 100644 --- a/backend-node/src/controllers/driverController.ts +++ b/backend-node/src/controllers/driverController.ts @@ -23,7 +23,7 @@ export class DriverController { // 사용자 정보 조회 const userResult = await query( `SELECT - user_id, user_name, cell_phone, license_number, vehicle_number, signup_type + user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name FROM user_info WHERE user_id = $1`, [userId] @@ -69,6 +69,7 @@ export class DriverController { vehicleNumber: user.vehicle_number, vehicleType: vehicle?.vehicle_type || null, vehicleStatus: vehicle?.status || null, + branchName: user.branch_name || null, }, }); } catch (error) { @@ -96,7 +97,7 @@ export class DriverController { return; } - const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; + const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body; // 공차중계 사용자 확인 const userCheck = await query( @@ -144,9 +145,10 @@ export class DriverController { user_name = COALESCE($1, user_name), cell_phone = COALESCE($2, cell_phone), license_number = COALESCE($3, license_number), - vehicle_number = COALESCE($4, vehicle_number) - WHERE user_id = $5`, - [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, userId] + vehicle_number = COALESCE($4, vehicle_number), + branch_name = COALESCE($5, branch_name) + WHERE user_id = $6`, + [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId] ); // vehicles 테이블 업데이트 @@ -156,9 +158,10 @@ export class DriverController { vehicle_type = COALESCE($2, vehicle_type), driver_name = COALESCE($3, driver_name), driver_phone = COALESCE($4, driver_phone), + branch_name = COALESCE($5, branch_name), updated_at = NOW() - WHERE user_id = $5`, - [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, userId] + WHERE user_id = $6`, + [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId] ); logger.info(`운전자 프로필 수정 완료: ${userId}`); @@ -239,6 +242,160 @@ export class DriverController { } } + /** + * DELETE /api/driver/vehicle + * 차량 삭제 (user_id = NULL 처리, 기록 보존) + */ + static async deleteVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존) + await query( + `UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`, + [userId] + ); + + // user_info에서 vehicle_number를 NULL로 변경 + await query( + `UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`, + [userId] + ); + + logger.info(`차량 삭제 완료 (기록 보존): ${userId}`); + + res.status(200).json({ + success: true, + message: "차량이 삭제되었습니다.", + }); + } catch (error) { + logger.error("차량 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "차량 삭제 중 오류가 발생했습니다.", + }); + } + } + + /** + * POST /api/driver/vehicle + * 새 차량 등록 + */ + static async registerVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + const companyCode = req.user?.companyCode; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { vehicleNumber, vehicleType, branchName } = req.body; + + if (!vehicleNumber) { + res.status(400).json({ + success: false, + message: "차량번호는 필수입니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // 이미 차량이 있는지 확인 + if (userCheck[0].vehicle_number) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.", + }); + return; + } + + // 차량번호 중복 확인 + const duplicateCheck = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`, + [vehicleNumber] + ); + + if (duplicateCheck.length > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량번호입니다.", + }); + return; + } + + const userName = userCheck[0].user_name; + const userPhone = userCheck[0].cell_phone; + // 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용) + const userCompanyCode = companyCode || userCheck[0].company_code; + + // vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off') + await query( + `INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`, + [vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode] + ); + + // user_info에 vehicle_number 업데이트 + await query( + `UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`, + [vehicleNumber, userId] + ); + + logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`); + + res.status(200).json({ + success: true, + message: "차량이 등록되었습니다.", + }); + } catch (error) { + logger.error("차량 등록 오류:", error); + res.status(500).json({ + success: false, + message: "차량 등록 중 오류가 발생했습니다.", + }); + } + } + /** * DELETE /api/driver/account * 회원 탈퇴 (차량 정보 포함 삭제) diff --git a/backend-node/src/routes/driverRoutes.ts b/backend-node/src/routes/driverRoutes.ts index 29a68244..b46cca1b 100644 --- a/backend-node/src/routes/driverRoutes.ts +++ b/backend-node/src/routes/driverRoutes.ts @@ -26,6 +26,18 @@ router.put("/profile", DriverController.updateProfile); */ router.put("/status", DriverController.updateStatus); +/** + * DELETE /api/driver/vehicle + * 차량 삭제 (기록 보존) + */ +router.delete("/vehicle", DriverController.deleteVehicle); + +/** + * POST /api/driver/vehicle + * 새 차량 등록 + */ +router.post("/vehicle", DriverController.registerVehicle); + /** * DELETE /api/driver/account * 회원 탈퇴 diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 51b939a4..318266da 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -236,11 +236,19 @@ function AppLayoutInner({ children }: AppLayoutProps) { saveProfile, // 운전자 관련 isDriver, + hasVehicle, driverInfo, driverFormData, updateDriverFormData, handleDriverStatusChange, handleDriverAccountDelete, + handleDeleteVehicle, + openVehicleRegisterModal, + closeVehicleRegisterModal, + isVehicleRegisterModalOpen, + newVehicleData, + updateNewVehicleData, + handleRegisterVehicle, } = useProfile(user, refreshUserData, refreshMenus); // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) @@ -491,11 +499,19 @@ function AppLayoutInner({ children }: AppLayoutProps) { departments={departments} alertModal={alertModal} isDriver={isDriver} + hasVehicle={hasVehicle} driverInfo={driverInfo} driverFormData={driverFormData} onDriverFormChange={updateDriverFormData} onDriverStatusChange={handleDriverStatusChange} onDriverAccountDelete={handleDriverAccountDelete} + onDeleteVehicle={handleDeleteVehicle} + onOpenVehicleRegisterModal={openVehicleRegisterModal} + isVehicleRegisterModalOpen={isVehicleRegisterModalOpen} + newVehicleData={newVehicleData} + onCloseVehicleRegisterModal={closeVehicleRegisterModal} + onNewVehicleDataChange={updateNewVehicleData} + onRegisterVehicle={handleRegisterVehicle} onClose={closeProfileModal} onFormChange={updateFormData} onImageSelect={selectImage} diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index 8fd2190a..e79d3357 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -11,9 +11,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Camera, X, Car, Wrench, Clock } from "lucide-react"; +import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react"; import { ProfileFormData } from "@/types/profile"; import { Separator } from "@/components/ui/separator"; +import { VehicleRegisterData } from "@/lib/api/driver"; // 운전자 정보 타입 export interface DriverInfo { @@ -22,6 +23,7 @@ export interface DriverInfo { licenseNumber: string; phoneNumber: string; vehicleStatus: string | null; + branchName: string | null; } // 알림 모달 컴포넌트 @@ -70,6 +72,7 @@ export interface DriverFormData { vehicleType: string; licenseNumber: string; phoneNumber: string; + branchName: string; } interface ProfileModalProps { @@ -90,11 +93,21 @@ interface ProfileModalProps { }; // 운전자 관련 props (선택적) isDriver?: boolean; + hasVehicle?: boolean; driverInfo?: DriverInfo | null; driverFormData?: DriverFormData; onDriverFormChange?: (field: keyof DriverFormData, value: string) => void; onDriverStatusChange?: (status: "off" | "maintenance") => void; onDriverAccountDelete?: () => void; + // 차량 삭제/등록 관련 props + onDeleteVehicle?: () => void; + onOpenVehicleRegisterModal?: () => void; + // 새 차량 등록 모달 관련 props + isVehicleRegisterModalOpen?: boolean; + newVehicleData?: VehicleRegisterData; + onCloseVehicleRegisterModal?: () => void; + onNewVehicleDataChange?: (field: keyof VehicleRegisterData, value: string) => void; + onRegisterVehicle?: () => void; onClose: () => void; onFormChange: (field: keyof ProfileFormData, value: string) => void; onImageSelect: (event: React.ChangeEvent) => void; @@ -115,11 +128,19 @@ export function ProfileModal({ departments, alertModal, isDriver = false, + hasVehicle = false, driverInfo, driverFormData, onDriverFormChange, onDriverStatusChange, onDriverAccountDelete, + onDeleteVehicle, + onOpenVehicleRegisterModal, + isVehicleRegisterModalOpen = false, + newVehicleData, + onCloseVehicleRegisterModal, + onNewVehicleDataChange, + onRegisterVehicle, onClose, onFormChange, onImageSelect, @@ -282,96 +303,147 @@ export function ProfileModal({ {/* 운전자 정보 섹션 (공차중계 사용자만) */} - {isDriver && driverFormData && onDriverFormChange && ( + {isDriver && ( <>
-
- -

차량/운전자 정보

+
+
+ +

차량/운전자 정보

+
+ {/* 차량 유무에 따른 버튼 표시 */} + {hasVehicle ? ( + + ) : ( + + )}
-
-
- - onDriverFormChange("vehicleNumber", e.target.value)} - placeholder="12가1234" - /> -
-
- - onDriverFormChange("vehicleType", e.target.value)} - placeholder="1톤 카고" - /> -
-
+ {/* 운전자 정보 (항상 수정 가능) */} + {driverFormData && onDriverFormChange && ( + <> + {/* 차량 정보 - 차량이 있을 때만 수정 가능 */} + {hasVehicle ? ( +
+
+ + onDriverFormChange("vehicleNumber", e.target.value)} + placeholder="12가1234" + /> +
+
+ + onDriverFormChange("vehicleType", e.target.value)} + placeholder="1톤 카고" + /> +
+
+ ) : ( + /* 차량이 없는 경우: 안내 메시지 */ +
+ +

등록된 차량이 없습니다.

+

새 차량 등록 버튼을 눌러 차량을 등록하세요.

+
+ )} -
-
- - onDriverFormChange("phoneNumber", e.target.value)} - placeholder="010-1234-5678" - /> -
-
- - onDriverFormChange("licenseNumber", e.target.value)} - placeholder="12-34-567890-12" - /> -
-
- - {/* 차량 상태 */} - {driverInfo && onDriverStatusChange && ( -
- -
- - {getStatusLabel(driverInfo.vehicleStatus)} - -
- - + {/* 운전자 개인 정보 - 항상 수정 가능 */} +
+
+ + onDriverFormChange("phoneNumber", e.target.value)} + placeholder="010-1234-5678" + /> +
+
+ + onDriverFormChange("licenseNumber", e.target.value)} + placeholder="12-34-567890-12" + />
-

- * 운행/공차 상태는 공차등록 화면에서 변경하세요 -

-
- )} +
+ + onDriverFormChange("branchName", e.target.value)} + placeholder="서울 본점" + /> +
+ + {/* 차량 상태 - 차량이 있을 때만 표시 */} + {hasVehicle && driverInfo && onDriverStatusChange && ( +
+ +
+ + {getStatusLabel(driverInfo.vehicleStatus)} + +
+ + +
+
+

+ * 운행/공차 상태는 공차등록 화면에서 변경하세요 +

+
+ )} + + )}
)} @@ -396,6 +468,50 @@ export function ProfileModal({ message={alertModal.message} type={alertModal.type} /> + + {/* 새 차량 등록 모달 */} + {isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && ( + + + + 새 차량 등록 + + 새로운 차량 정보를 입력해주세요. + + + +
+
+ + onNewVehicleDataChange("vehicleNumber", e.target.value)} + placeholder="12가1234" + /> +
+
+ + onNewVehicleDataChange("vehicleType", e.target.value)} + placeholder="1톤 카고" + /> +
+
+ + + + + +
+
+ )} ); } diff --git a/frontend/hooks/useProfile.ts b/frontend/hooks/useProfile.ts index bae5e9d7..f96593f5 100644 --- a/frontend/hooks/useProfile.ts +++ b/frontend/hooks/useProfile.ts @@ -9,7 +9,10 @@ import { updateDriverProfile, updateDriverStatus, deleteDriverAccount, + deleteDriverVehicle, + registerDriverVehicle, DriverProfile, + VehicleRegisterData, } from "@/lib/api/driver"; import { DriverInfo, DriverFormData } from "@/components/layout/ProfileModal"; @@ -58,12 +61,22 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr // 운전자 정보 상태 const [isDriver, setIsDriver] = useState(false); + const [hasVehicle, setHasVehicle] = useState(false); // 차량 보유 여부 const [driverInfo, setDriverInfo] = useState(null); const [driverFormData, setDriverFormData] = useState({ vehicleNumber: "", vehicleType: "", licenseNumber: "", phoneNumber: "", + branchName: "", + }); + + // 새 차량 등록 모달 상태 + const [isVehicleRegisterModalOpen, setIsVehicleRegisterModalOpen] = useState(false); + const [newVehicleData, setNewVehicleData] = useState({ + vehicleNumber: "", + vehicleType: "", + branchName: "", }); // 알림 모달 표시 함수 @@ -99,21 +112,27 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr const response = await getDriverProfile(); if (response.success && response.data) { setIsDriver(true); + // 차량 보유 여부 확인 + const vehicleExists = !!response.data.vehicleNumber; + setHasVehicle(vehicleExists); setDriverInfo({ vehicleNumber: response.data.vehicleNumber, vehicleType: response.data.vehicleType, licenseNumber: response.data.licenseNumber, phoneNumber: response.data.phoneNumber, vehicleStatus: response.data.vehicleStatus, + branchName: response.data.branchName, }); setDriverFormData({ vehicleNumber: response.data.vehicleNumber || "", vehicleType: response.data.vehicleType || "", licenseNumber: response.data.licenseNumber || "", phoneNumber: response.data.phoneNumber || "", + branchName: response.data.branchName || "", }); } else { setIsDriver(false); + setHasVehicle(false); setDriverInfo(null); } } catch (error) { @@ -229,6 +248,83 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr } }, [showAlert]); + /** + * 차량 삭제 + */ + const handleDeleteVehicle = useCallback(async () => { + if (!confirm("이 차량을 더 이상 사용하지 않습니까?\n차량 정보가 삭제됩니다.")) { + return; + } + + try { + const response = await deleteDriverVehicle(); + if (response.success) { + showAlert("삭제 완료", "차량이 삭제되었습니다.", "success"); + // 운전자 정보 새로고침 + await loadDriverInfo(); + } else { + showAlert("삭제 실패", response.message || "차량 삭제에 실패했습니다.", "error"); + } + } catch (error) { + console.error("차량 삭제 실패:", error); + showAlert("오류", "차량 삭제 중 오류가 발생했습니다.", "error"); + } + }, [showAlert, loadDriverInfo]); + + /** + * 새 차량 등록 모달 열기 + */ + const openVehicleRegisterModal = useCallback(() => { + setNewVehicleData({ + vehicleNumber: "", + vehicleType: "", + branchName: driverFormData.branchName || "", // 기존 소속 지점 유지 + }); + setIsVehicleRegisterModalOpen(true); + }, [driverFormData.branchName]); + + /** + * 새 차량 등록 모달 닫기 + */ + const closeVehicleRegisterModal = useCallback(() => { + setIsVehicleRegisterModalOpen(false); + }, []); + + /** + * 새 차량 데이터 변경 + */ + const updateNewVehicleData = useCallback((field: keyof VehicleRegisterData, value: string) => { + setNewVehicleData((prev) => ({ + ...prev, + [field]: value, + })); + }, []); + + /** + * 새 차량 등록 처리 + */ + const handleRegisterVehicle = useCallback(async () => { + if (!newVehicleData.vehicleNumber) { + showAlert("입력 오류", "차량번호는 필수입니다.", "error"); + return; + } + + try { + const response = await registerDriverVehicle(newVehicleData); + if (response.success) { + showAlert("등록 완료", "차량이 등록되었습니다.", "success"); + setIsVehicleRegisterModalOpen(false); + // 운전자 정보 새로고침 + await loadDriverInfo(); + } else { + showAlert("등록 실패", response.message || "차량 등록에 실패했습니다.", "error"); + } + } catch (error) { + console.error("차량 등록 실패:", error); + showAlert("오류", "차량 등록 중 오류가 발생했습니다.", "error"); + } + }, [newVehicleData, showAlert, loadDriverInfo]); + /** * 이미지 선택 처리 */ @@ -341,6 +437,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr licenseNumber: driverFormData.licenseNumber, vehicleNumber: driverFormData.vehicleNumber, vehicleType: driverFormData.vehicleType, + branchName: driverFormData.branchName, }); if (!driverResponse.success) { @@ -400,9 +497,14 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr // 운전자 관련 상태 isDriver, + hasVehicle, driverInfo, driverFormData, + // 새 차량 등록 모달 상태 + isVehicleRegisterModalOpen, + newVehicleData, + // 액션 openProfileModal, closeProfileModal, @@ -415,5 +517,10 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr updateDriverFormData, handleDriverStatusChange, handleDriverAccountDelete, + handleDeleteVehicle, + openVehicleRegisterModal, + closeVehicleRegisterModal, + updateNewVehicleData, + handleRegisterVehicle, }; }; diff --git a/frontend/lib/api/driver.ts b/frontend/lib/api/driver.ts index 8600c5cb..8074660a 100644 --- a/frontend/lib/api/driver.ts +++ b/frontend/lib/api/driver.ts @@ -9,6 +9,7 @@ export interface DriverProfile { vehicleNumber: string; vehicleType: string | null; vehicleStatus: string | null; + branchName: string | null; } export interface DriverProfileUpdateData { @@ -17,6 +18,7 @@ export interface DriverProfileUpdateData { licenseNumber?: string; vehicleNumber?: string; vehicleType?: string; + branchName?: string; } /** @@ -72,6 +74,47 @@ export async function updateDriverStatus( } } +/** + * 차량 삭제 (기록 보존) + */ +export async function deleteDriverVehicle(): Promise<{ + success: boolean; + message?: string; +}> { + try { + const response = await apiClient.delete("/driver/vehicle"); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "차량 삭제에 실패했습니다.", + }; + } +} + +/** + * 새 차량 등록 + */ +export interface VehicleRegisterData { + vehicleNumber: string; + vehicleType?: string; + branchName?: string; +} + +export async function registerDriverVehicle( + data: VehicleRegisterData +): Promise<{ success: boolean; message?: string }> { + try { + const response = await apiClient.post("/driver/vehicle", data); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "차량 등록에 실패했습니다.", + }; + } +} + /** * 회원 탈퇴 */