traeon/frontend/src/components/ParcelDialog.jsx
caadiq 54a785c149 fix: 버그 수정 4건
- 한진택배 UNKNOWN 테이블 헤더 이벤트 필터링
- 다이얼로그 수정 상태 초기화 (parcelId 변경 시)
- 모바일 수정 input 오버플로우 수정 (min-w-0)
- 이벤트 시간 KST 변환 수정 (toMysqlDatetime)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:11:28 +09:00

287 lines
12 KiB
JavaScript

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { X, Trash2, RefreshCw, Pencil, Check, Loader2, Copy } from "lucide-react";
import { useState, useEffect } from "react";
import useToastStore from "@/stores/useToastStore";
import dayjs from "dayjs";
import {
fetchParcel,
deleteParcel,
updateParcel,
refreshParcel,
} from "@/api/parcels";
import TrackingTimeline from "./TrackingTimeline";
import StatusBadge from "./StatusBadge";
function ParcelDialog({ parcelId, onClose }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [editLabel, setEditLabel] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const showToast = useToastStore((s) => s.show);
useEffect(() => {
setEditing(false);
setEditLabel("");
setShowDeleteConfirm(false);
}, [parcelId]);
const { data: parcel, isLoading } = useQuery({
queryKey: ["parcel", parcelId],
queryFn: () => fetchParcel(parcelId),
enabled: !!parcelId,
});
const deleteMutation = useMutation({
mutationFn: () => deleteParcel(parcelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
showToast("택배가 삭제되었습니다");
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (label) => updateParcel(parcelId, { label }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcel", parcelId] });
queryClient.invalidateQueries({ queryKey: ["parcels"] });
setEditing(false);
},
});
const refreshMutation = useMutation({
mutationFn: () => refreshParcel(parcelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcel", parcelId] });
queryClient.invalidateQueries({ queryKey: ["parcels"] });
},
});
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
};
const handleEditStart = () => {
setEditLabel(parcel?.label || "");
setEditing(true);
};
const handleEditSave = () => {
updateMutation.mutate(editLabel);
};
return (
<>
<AnimatePresence>
{parcelId && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/40 z-40"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{isLoading || !parcel ? (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
) : (
<>
{/* 헤더 */}
<div className="flex-shrink-0 p-5 lg:p-6 border-b border-gray-100">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1.5">
{editing ? (
<div className="flex items-center gap-3 h-[28px] lg:h-[32px]">
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="h-full border-0 border-b-2 border-primary bg-transparent px-0 text-lg lg:text-xl font-semibold text-gray-900 flex-1 min-w-0 focus:outline-none focus:border-primary-dark placeholder:text-gray-300"
placeholder="별칭을 입력하세요"
autoFocus
onKeyDown={(e) =>
e.key === "Enter" && handleEditSave()
}
/>
<button
onClick={handleEditSave}
className="text-green-600 hover:text-green-700 shrink-0 p-1"
>
<Check size={16} />
</button>
<button
onClick={() => setEditing(false)}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<X size={16} />
</button>
</div>
) : (
<div className="flex items-center gap-2.5 h-[28px] lg:h-[32px]">
<h2 className="font-semibold text-lg lg:text-xl truncate leading-none">
{parcel.label || parcel.tracking_number}
</h2>
<button
onClick={handleEditStart}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<Pencil size={14} />
</button>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-500 shrink-0 p-1"
>
<Trash2 size={14} />
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{parcel.carrier_name}</span>
<span className="text-gray-300">|</span>
<button
onClick={() => {
navigator.clipboard.writeText(parcel.tracking_number);
showToast("운송장 번호가 복사되었습니다");
}}
className="flex items-center gap-1 tracking-letter-spacing hover:text-primary transition-colors"
>
{parcel.tracking_number}
<Copy size={11} className="opacity-40" />
</button>
</div>
{(parcel.sender_name || parcel.recipient_name) && (
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5 flex-wrap">
{parcel.sender_name && (
<span>보내는 : {parcel.sender_name}</span>
)}
{parcel.recipient_name && (
<span>받는 : {parcel.recipient_name}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={parcel.status} />
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
>
<X size={18} />
</button>
</div>
</div>
</div>
{/* 배송 추적 - 내부 스크롤 */}
<div className="flex-1 min-h-0 overflow-y-auto p-5 lg:p-6">
<div className="flex items-center justify-between mb-3 lg:mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base text-gray-700">
배송 추적
</h3>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="text-primary hover:text-primary-dark transition-colors disabled:opacity-50"
>
<RefreshCw
size={14}
className={refreshMutation.isPending ? "animate-spin" : ""}
/>
</button>
</div>
{parcel.last_checked_at && (
<span className="text-xs text-gray-400">
{dayjs(parcel.last_checked_at).format("MM.DD HH:mm")} 조회
</span>
)}
</div>
{parcel.events?.length > 0 ? (
<TrackingTimeline events={parcel.events} />
) : (
<p className="text-sm text-gray-400 text-center py-4">
배송 정보가 아직 없습니다
</p>
)}
</div>
</>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
<AnimatePresence>
{showDeleteConfirm && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-[60]"
onClick={() => setShowDeleteConfirm(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-[70] flex items-center justify-center p-4"
onClick={() => setShowDeleteConfirm(false)}
>
<div
className="bg-white rounded-xl shadow-xl p-6 w-full max-w-xs space-y-4"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm lg:text-base text-gray-700 text-center">
택배를 삭제할까요?
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 px-4 py-2 text-sm text-gray-500 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
취소
</button>
<button
onClick={confirmDelete}
className="flex-1 px-4 py-2 text-sm text-white bg-red-500 rounded-lg hover:bg-red-600 transition-colors"
>
삭제
</button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
export default ParcelDialog;