feat: 공차중계 운전자 차량/프로필 관리 기능 구현

This commit is contained in:
dohyeons
2025-12-01 19:03:43 +09:00
parent 9c3f1d26ad
commit cd47f569e2
6 changed files with 540 additions and 89 deletions

View File

@@ -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}

View File

@@ -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<HTMLInputElement>) => 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({
</div>
{/* 운전자 정보 섹션 (공차중계 사용자만) */}
{isDriver && driverFormData && onDriverFormChange && (
{isDriver && (
<>
<Separator className="my-4" />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Car className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold">/ </h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Car className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold">/ </h3>
</div>
{/* 차량 유무에 따른 버튼 표시 */}
{hasVehicle ? (
<Button
type="button"
variant="destructive"
size="sm"
onClick={onDeleteVehicle}
className="flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
onClick={onOpenVehicleRegisterModal}
className="flex items-center gap-1"
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vehicleNumber"></Label>
<Input
id="vehicleNumber"
value={driverFormData.vehicleNumber}
onChange={(e) => onDriverFormChange("vehicleNumber", e.target.value)}
placeholder="12가1234"
/>
</div>
<div className="space-y-2">
<Label htmlFor="vehicleType"></Label>
<Input
id="vehicleType"
value={driverFormData.vehicleType}
onChange={(e) => onDriverFormChange("vehicleType", e.target.value)}
placeholder="1톤 카고"
/>
</div>
</div>
{/* 운전자 정보 (항상 수정 가능) */}
{driverFormData && onDriverFormChange && (
<>
{/* 차량 정보 - 차량이 있을 때만 수정 가능 */}
{hasVehicle ? (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vehicleNumber"></Label>
<Input
id="vehicleNumber"
value={driverFormData.vehicleNumber}
onChange={(e) => onDriverFormChange("vehicleNumber", e.target.value)}
placeholder="12가1234"
/>
</div>
<div className="space-y-2">
<Label htmlFor="vehicleType"></Label>
<Input
id="vehicleType"
value={driverFormData.vehicleType}
onChange={(e) => onDriverFormChange("vehicleType", e.target.value)}
placeholder="1톤 카고"
/>
</div>
</div>
) : (
/* 차량이 없는 경우: 안내 메시지 */
<div className="text-center py-4 text-muted-foreground border rounded-md bg-muted/30">
<Car className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-sm"> .</p>
<p className="text-xs mt-1"> .</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="driverPhone"></Label>
<Input
id="driverPhone"
value={driverFormData.phoneNumber}
onChange={(e) => onDriverFormChange("phoneNumber", e.target.value)}
placeholder="010-1234-5678"
/>
</div>
<div className="space-y-2">
<Label htmlFor="licenseNumber"></Label>
<Input
id="licenseNumber"
value={driverFormData.licenseNumber}
onChange={(e) => onDriverFormChange("licenseNumber", e.target.value)}
placeholder="12-34-567890-12"
/>
</div>
</div>
{/* 차량 상태 */}
{driverInfo && onDriverStatusChange && (
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-4">
<span className="text-sm font-medium px-3 py-1 rounded-full bg-muted">
{getStatusLabel(driverInfo.vehicleStatus)}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDriverStatusChange("off")}
disabled={driverInfo.vehicleStatus === "off"}
className="flex items-center gap-1"
>
<Clock className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDriverStatusChange("maintenance")}
disabled={driverInfo.vehicleStatus === "maintenance"}
className="flex items-center gap-1"
>
<Wrench className="h-3 w-3" />
</Button>
{/* 운전자 개인 정보 - 항상 수정 가능 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="driverPhone"></Label>
<Input
id="driverPhone"
value={driverFormData.phoneNumber}
onChange={(e) => onDriverFormChange("phoneNumber", e.target.value)}
placeholder="010-1234-5678"
/>
</div>
<div className="space-y-2">
<Label htmlFor="licenseNumber"></Label>
<Input
id="licenseNumber"
value={driverFormData.licenseNumber}
onChange={(e) => onDriverFormChange("licenseNumber", e.target.value)}
placeholder="12-34-567890-12"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
* /
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="branchName"> </Label>
<Input
id="branchName"
value={driverFormData.branchName}
onChange={(e) => onDriverFormChange("branchName", e.target.value)}
placeholder="서울 본점"
/>
</div>
{/* 차량 상태 - 차량이 있을 때만 표시 */}
{hasVehicle && driverInfo && onDriverStatusChange && (
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-4">
<span className="text-sm font-medium px-3 py-1 rounded-full bg-muted">
{getStatusLabel(driverInfo.vehicleStatus)}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDriverStatusChange("off")}
disabled={driverInfo.vehicleStatus === "off"}
className="flex items-center gap-1"
>
<Clock className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDriverStatusChange("maintenance")}
disabled={driverInfo.vehicleStatus === "maintenance"}
className="flex items-center gap-1"
>
<Wrench className="h-3 w-3" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
* /
</p>
</div>
)}
</>
)}
</div>
</>
)}
@@ -396,6 +468,50 @@ export function ProfileModal({
message={alertModal.message}
type={alertModal.type}
/>
{/* 새 차량 등록 모달 */}
{isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && (
<ResizableDialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
<ResizableDialogContent className="sm:max-w-[400px]">
<ResizableDialogHeader>
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription>
.
</ResizableDialogDescription>
</ResizableDialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="newVehicleNumber"> *</Label>
<Input
id="newVehicleNumber"
value={newVehicleData.vehicleNumber}
onChange={(e) => onNewVehicleDataChange("vehicleNumber", e.target.value)}
placeholder="12가1234"
/>
</div>
<div className="space-y-2">
<Label htmlFor="newVehicleType"></Label>
<Input
id="newVehicleType"
value={newVehicleData.vehicleType || ""}
onChange={(e) => onNewVehicleDataChange("vehicleType", e.target.value)}
placeholder="1톤 카고"
/>
</div>
</div>
<ResizableDialogFooter>
<Button type="button" variant="outline" onClick={onCloseVehicleRegisterModal}>
</Button>
<Button type="button" onClick={onRegisterVehicle}>
</Button>
</ResizableDialogFooter>
</ResizableDialogContent>
</ResizableDialog>
)}
</>
);
}

View File

@@ -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<void>, refr
// 운전자 정보 상태
const [isDriver, setIsDriver] = useState(false);
const [hasVehicle, setHasVehicle] = useState(false); // 차량 보유 여부
const [driverInfo, setDriverInfo] = useState<DriverInfo | null>(null);
const [driverFormData, setDriverFormData] = useState<DriverFormData>({
vehicleNumber: "",
vehicleType: "",
licenseNumber: "",
phoneNumber: "",
branchName: "",
});
// 새 차량 등록 모달 상태
const [isVehicleRegisterModalOpen, setIsVehicleRegisterModalOpen] = useState(false);
const [newVehicleData, setNewVehicleData] = useState<VehicleRegisterData>({
vehicleNumber: "",
vehicleType: "",
branchName: "",
});
// 알림 모달 표시 함수
@@ -99,21 +112,27 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, 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<void>, 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<void>, 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<void>, refr
// 운전자 관련 상태
isDriver,
hasVehicle,
driverInfo,
driverFormData,
// 새 차량 등록 모달 상태
isVehicleRegisterModalOpen,
newVehicleData,
// 액션
openProfileModal,
closeProfileModal,
@@ -415,5 +517,10 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
updateDriverFormData,
handleDriverStatusChange,
handleDriverAccountDelete,
handleDeleteVehicle,
openVehicleRegisterModal,
closeVehicleRegisterModal,
updateNewVehicleData,
handleRegisterVehicle,
};
};

View File

@@ -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 || "차량 등록에 실패했습니다.",
};
}
}
/**
* 회원 탈퇴
*/