feat: 프론트엔드 API 연동 + 다이얼로그 + 무한스크롤

- 더미 데이터 → React Query + 실제 API 호출로 전환
- 상세 페이지 → 모달 다이얼로그로 변경
- 무한 스크롤 페이징 (useInfiniteQuery + react-virtual)
- 내부 스크롤 적용 (전체 페이지 스크롤 제거)
- 보내는 분/받는 분 정보 표시
- 삭제 확인 커스텀 다이얼로그
- DB 타임존 KST 설정 (dateStrings)
- 백엔드 페이징 API 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-24 19:36:40 +09:00
parent d9ba70de16
commit 458efe3e5d
21 changed files with 738 additions and 329 deletions

View file

@ -11,5 +11,7 @@ export default {
database: process.env.DB_NAME || 'traeon',
connectionLimit: 10,
waitForConnections: true,
timezone: '+09:00',
dateStrings: true,
},
};

View file

@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS parcels (
carrier_name VARCHAR(100) NOT NULL,
tracking_number VARCHAR(100) NOT NULL,
label VARCHAR(200),
sender_name VARCHAR(100),
recipient_name VARCHAR(100),
status VARCHAR(50) DEFAULT 'PENDING',
last_detail TEXT,
last_checked_at DATETIME,
@ -69,6 +71,14 @@ async function dbPlugin(fastify) {
}
fastify.log.info('테이블 초기화 완료');
// 마이그레이션: sender_name, recipient_name 컬럼 추가
try {
await pool.execute('ALTER TABLE parcels ADD COLUMN sender_name VARCHAR(100) AFTER label');
} catch (e) { /* 이미 존재 */ }
try {
await pool.execute('ALTER TABLE parcels ADD COLUMN recipient_name VARCHAR(100) AFTER sender_name');
} catch (e) { /* 이미 존재 */ }
// 기본 택배사 데이터 삽입
for (const [id, name, shortName, color, logoUrl] of DEFAULT_CARRIERS) {
await pool.execute(

View file

@ -15,6 +15,8 @@ const TRACK_QUERY = `
query Track($carrierId: ID!, $trackingNumber: String!) {
track(carrierId: $carrierId, trackingNumber: $trackingNumber) {
trackingNumber
sender { name }
recipient { name }
lastEvent {
status { code name }
time
@ -89,6 +91,8 @@ async function trackerPlugin(fastify) {
return {
trackingNumber: trackInfo.trackingNumber,
senderName: trackInfo.sender?.name || null,
recipientName: trackInfo.recipient?.name || null,
lastEvent,
events,
};

View file

@ -1,20 +1,40 @@
export default async function parcelRoutes(fastify) {
// 전체 택배 목록
// 전체 택배 목록 (페이징)
fastify.get('/', async (request) => {
const { status } = request.query;
let sql = 'SELECT * FROM parcels ORDER BY created_at DESC';
const { status, page = 1, limit = 20 } = request.query;
const offset = (Math.max(1, Number(page)) - 1) * Number(limit);
const lim = Math.min(100, Math.max(1, Number(limit)));
let where = '';
const params = [];
if (status === 'active') {
sql = 'SELECT * FROM parcels WHERE status != ? ORDER BY created_at DESC';
where = 'WHERE status != ?';
params.push('DELIVERED');
} else if (status === 'delivered') {
sql = 'SELECT * FROM parcels WHERE status = ? ORDER BY created_at DESC';
where = 'WHERE status = ?';
params.push('DELIVERED');
}
const [rows] = await fastify.db.execute(sql, params);
return rows;
const [[{ total }]] = await fastify.db.execute(
`SELECT COUNT(*) as total FROM parcels ${where}`,
params
);
const [rows] = await fastify.db.execute(
`SELECT * FROM parcels ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...params, String(lim), String(offset)]
);
return {
data: rows,
pagination: {
page: Number(page),
limit: lim,
total,
totalPages: Math.ceil(total / lim),
},
};
});
// 운송장 등록
@ -180,8 +200,11 @@ async function refreshParcel(fastify, parcelId) {
await fastify.db.execute(
`UPDATE parcels SET status = ?, last_detail = ?, last_checked_at = NOW(),
delivered_at = COALESCE(?, delivered_at) WHERE id = ?`,
[status, lastDetail, deliveredAt, parcelId]
delivered_at = COALESCE(?, delivered_at),
sender_name = COALESCE(?, sender_name),
recipient_name = COALESCE(?, recipient_name)
WHERE id = ?`,
[status, lastDetail, deliveredAt, result.senderName, result.recipientName, parcelId]
);
// 기존 이벤트 삭제 후 새로 삽입

View file

@ -166,9 +166,11 @@ CREATE TABLE tracking_events (
- [x] delivery-tracker 셀프호스팅 (Docker 이미지 빌드, Apollo Server 포트 4000)
- [x] delivery-tracker GraphQL 클라이언트 플러그인 (tracker.js)
- [x] API 라우트 구현 (parcels CRUD + carriers 목록 + 수동 새로고침)
- [ ] Frontend를 더미 데이터에서 실제 API로 전환
- [x] Frontend를 더미 데이터에서 실제 API로 전환
- Vite 프록시 설정 (`/api/` → backend)
- React Query로 데이터 페칭 + 자동 리페칭
- React Query로 데이터 페칭 (택배 목록, 상세, 택배사 목록)
- 등록/수정/삭제/새로고침 mutation + 캐시 무효화
- 택배사 로고를 RustFS URL에서 로드
- [ ] 자동갱신 cron 서비스 (node-cron)
- 배송 중인 택배: 30분 간격 자동 조회
- 배송 완료된 택배: 조회 중단

View file

@ -8,6 +8,8 @@
"name": "traeon-frontend",
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
"dayjs": "^1.11.19",
"framer-motion": "^11.0.8",
"lucide-react": "^0.344.0",
@ -1164,6 +1166,59 @@
"win32"
]
},
"node_modules/@tanstack/query-core": {
"version": "5.95.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
"integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.95.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.95.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View file

@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
"dayjs": "^1.11.19",
"framer-motion": "^11.0.8",
"lucide-react": "^0.344.0",

View file

@ -1,11 +1,9 @@
import { Routes, Route } from "react-router-dom";
import MainPage from "@/pages/MainPage";
import DetailPage from "@/pages/DetailPage";
function App() {
return (
<div className="min-h-screen bg-secondary">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="h-screen overflow-hidden flex flex-col bg-secondary">
<header className="flex-shrink-0 bg-white border-b border-gray-200 z-10">
<div className="max-w-2xl lg:max-w-4xl mx-auto px-4 py-3 lg:py-4 flex items-center gap-2">
<a href="/" className="flex items-center gap-2 no-underline">
<span className="text-xl lg:text-2xl font-bold text-primary">Traeon</span>
@ -13,11 +11,10 @@ function App() {
</a>
</div>
</header>
<main className="max-w-2xl lg:max-w-4xl mx-auto px-4 py-6 lg:py-8">
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/parcel/:id" element={<DetailPage />} />
</Routes>
<main className="flex-1 min-h-0 overflow-hidden">
<div className="h-full max-w-2xl lg:max-w-4xl mx-auto px-4 py-6 lg:py-8 flex flex-col">
<MainPage />
</div>
</main>
</div>
);

View file

@ -0,0 +1,66 @@
const API = '/api';
export async function fetchParcels({ status, page = 1, limit = 20 } = {}) {
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
params.set('page', String(page));
params.set('limit', String(limit));
const res = await fetch(`${API}/parcels?${params}`);
if (!res.ok) throw new Error('택배 목록 조회 실패');
const json = await res.json();
return {
...json,
hasNextPage: json.pagination.page < json.pagination.totalPages,
};
}
export async function fetchParcel(id) {
const res = await fetch(`${API}/parcels/${id}`);
if (!res.ok) throw new Error('택배 조회 실패');
return res.json();
}
export async function createParcel({ carrierId, trackingNumber, label }) {
const res = await fetch(`${API}/parcels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ carrierId, trackingNumber, label }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || '운송장 등록 실패');
}
return res.json();
}
export async function updateParcel(id, { label }) {
const res = await fetch(`${API}/parcels/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!res.ok) throw new Error('수정 실패');
return res.json();
}
export async function deleteParcel(id) {
const res = await fetch(`${API}/parcels/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('삭제 실패');
return res.json();
}
export async function refreshParcel(id) {
const res = await fetch(`${API}/parcels/${id}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error('새로고침 실패');
return res.json();
}
export async function fetchCarriers() {
const res = await fetch(`${API}/carriers`);
if (!res.ok) throw new Error('택배사 목록 조회 실패');
return res.json();
}

View file

@ -1,27 +0,0 @@
import { CARRIER_MAP } from "@/data/dummy";
function CarrierBadge({ carrierId }) {
const carrier = CARRIER_MAP[carrierId];
if (!carrier) return null;
if (carrier.logo) {
return (
<img
src={carrier.logo}
alt={carrier.name}
className="w-7 h-7 lg:w-8 lg:h-8 rounded-full object-contain shrink-0"
/>
);
}
return (
<span
className="inline-flex items-center justify-center w-7 h-7 lg:w-8 lg:h-8 rounded-full text-white text-[10px] lg:text-[11px] font-bold shrink-0"
style={{ backgroundColor: carrier.color }}
>
{carrier.short.length > 2 ? carrier.short.slice(0, 2) : carrier.short}
</span>
);
}
export default CarrierBadge;

View file

@ -1,13 +1,20 @@
import { useState, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { CARRIERS } from "@/data/dummy";
import CarrierBadge from "./CarrierBadge";
import { fetchCarriers } from "@/api/parcels";
function CarrierSelect({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const selected = CARRIERS.find((c) => c.id === value);
const { data: carriers = [] } = useQuery({
queryKey: ["carriers"],
queryFn: fetchCarriers,
staleTime: 1000 * 60 * 60,
});
const selected = carriers.find((c) => c.id === value);
useEffect(() => {
function handleClickOutside(e) {
@ -37,7 +44,7 @@ function CarrierSelect({ value, onChange }) {
>
{selected ? (
<>
<CarrierBadge carrierId={selected.id} />
<CarrierLogo carrier={selected} />
<span className="text-gray-900">{selected.name}</span>
</>
) : (
@ -60,7 +67,7 @@ function CarrierSelect({ value, onChange }) {
transition={{ duration: 0.15 }}
className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto py-1 origin-top"
>
{CARRIERS.map((carrier) => (
{carriers.map((carrier) => (
<li key={carrier.id}>
<button
type="button"
@ -71,7 +78,7 @@ function CarrierSelect({ value, onChange }) {
: "text-gray-700"
}`}
>
<CarrierBadge carrierId={carrier.id} />
<CarrierLogo carrier={carrier} />
<span>{carrier.name}</span>
</button>
</li>
@ -83,4 +90,25 @@ function CarrierSelect({ value, onChange }) {
);
}
function CarrierLogo({ carrier }) {
if (carrier.logo_url) {
return (
<img
src={carrier.logo_url}
alt={carrier.name}
className="w-7 h-7 lg:w-8 lg:h-8 rounded-full object-contain shrink-0"
/>
);
}
return (
<span
className="inline-flex items-center justify-center w-7 h-7 lg:w-8 lg:h-8 rounded-full text-white text-[10px] lg:text-[11px] font-bold shrink-0"
style={{ backgroundColor: carrier.color }}
>
{carrier.short_name?.slice(0, 2)}
</span>
);
}
export default CarrierSelect;

View file

@ -1,34 +1,40 @@
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import StatusBadge from "./StatusBadge";
import CarrierBadge from "./CarrierBadge";
import { fetchCarriers } from "@/api/parcels";
function ParcelCard({ parcel }) {
const navigate = useNavigate();
function ParcelCard({ parcel, onClick }) {
const { data: carriers = [] } = useQuery({
queryKey: ["carriers"],
queryFn: fetchCarriers,
staleTime: 1000 * 60 * 60,
});
const carrier = carriers.find((c) => c.id === parcel.carrier_id);
return (
<div
onClick={() => navigate(`/parcel/${parcel.id}`)}
onClick={onClick}
className="bg-white rounded-xl shadow-sm cursor-pointer hover:shadow-md transition-shadow flex items-center gap-3 lg:gap-4 p-3.5 lg:p-4"
>
<CarrierBadge carrierId={parcel.carrierId} />
<CarrierLogo carrier={carrier} />
<div className="flex-1 min-w-0 space-y-1.5 lg:space-y-2">
<div className="flex items-center gap-1.5 text-xs lg:text-sm text-gray-400">
<span>{parcel.carrierName}</span>
<span>{parcel.carrier_name}</span>
<span>|</span>
<span className="tracking-letter-spacing">
{parcel.trackingNumber}
{parcel.tracking_number}
</span>
</div>
<p className="font-semibold text-sm lg:text-base text-gray-900 truncate">
{parcel.label || parcel.trackingNumber}
{parcel.label || parcel.tracking_number}
</p>
<div className="flex items-center justify-between">
<span className="text-xs lg:text-sm text-gray-400">
{dayjs(parcel.createdAt).format("YYYY.MM.DD")}
{dayjs(parcel.created_at).format("YYYY.MM.DD")}
</span>
<StatusBadge status={parcel.status} />
</div>
@ -37,4 +43,27 @@ function ParcelCard({ parcel }) {
);
}
function CarrierLogo({ carrier }) {
if (!carrier) return <div className="w-7 h-7 lg:w-8 lg:h-8 shrink-0" />;
if (carrier.logo_url) {
return (
<img
src={carrier.logo_url}
alt={carrier.name}
className="w-7 h-7 lg:w-8 lg:h-8 rounded-full object-contain shrink-0"
/>
);
}
return (
<span
className="inline-flex items-center justify-center w-7 h-7 lg:w-8 lg:h-8 rounded-full text-white text-[10px] lg:text-[11px] font-bold shrink-0"
style={{ backgroundColor: carrier.color }}
>
{carrier.short_name?.slice(0, 2)}
</span>
);
}
export default ParcelCard;

View file

@ -0,0 +1,268 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { X, Trash2, RefreshCw, Pencil, Check, Loader2 } from "lucide-react";
import { useState } from "react";
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 { data: parcel, isLoading } = useQuery({
queryKey: ["parcel", parcelId],
queryFn: () => fetchParcel(parcelId),
enabled: !!parcelId,
});
const deleteMutation = useMutation({
mutationFn: () => deleteParcel(parcelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
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 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>
<span className="tracking-letter-spacing">
{parcel.tracking_number}
</span>
</div>
{(parcel.sender_name || parcel.recipient_name) && (
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
{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;

View file

@ -1,30 +1,35 @@
import { useState } from "react";
import { CARRIERS } from "@/data/dummy";
import useParcelStore from "@/stores/useParcelStore";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createParcel, fetchCarriers } from "@/api/parcels";
import CarrierSelect from "./CarrierSelect";
function ParcelForm({ onClose }) {
const addParcel = useParcelStore((s) => s.addParcel);
const queryClient = useQueryClient();
const [carrierId, setCarrierId] = useState("");
const [trackingNumber, setTrackingNumber] = useState("");
const [label, setLabel] = useState("");
const [error, setError] = useState("");
const mutation = useMutation({
mutationFn: createParcel,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
onClose();
},
onError: (err) => {
setError(err.message);
},
});
const handleSubmit = (e) => {
e.preventDefault();
if (!carrierId || !trackingNumber) return;
const carrier = CARRIERS.find((c) => c.id === carrierId);
addParcel({
setError("");
mutation.mutate({
carrierId,
carrierName: carrier.name,
trackingNumber: trackingNumber.replace(/\s/g, ""),
label: label || undefined,
});
setCarrierId("");
setTrackingNumber("");
setLabel("");
onClose();
};
return (
@ -32,16 +37,28 @@ function ParcelForm({ onClose }) {
onSubmit={handleSubmit}
className="bg-white rounded-xl p-5 lg:p-6 shadow-sm space-y-4 lg:space-y-5"
>
<h3 className="font-semibold text-sm lg:text-base text-gray-700">운송장 등록</h3>
<h3 className="font-semibold text-sm lg:text-base text-gray-700">
운송장 등록
</h3>
{error && (
<div className="text-sm text-red-500 bg-red-50 px-3 py-2 rounded-lg">
{error}
</div>
)}
<div className="space-y-3 lg:space-y-4">
<div>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">택배사</label>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">
택배사
</label>
<CarrierSelect value={carrierId} onChange={setCarrierId} />
</div>
<div>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">운송장 번호</label>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">
운송장 번호
</label>
<input
type="text"
value={trackingNumber}
@ -76,9 +93,10 @@ function ParcelForm({ onClose }) {
</button>
<button
type="submit"
className="px-4 lg:px-5 py-2 lg:py-2.5 bg-primary text-white rounded-lg text-sm lg:text-base font-medium hover:bg-primary-dark transition-colors"
disabled={mutation.isPending}
className="px-4 lg:px-5 py-2 lg:py-2.5 bg-primary text-white rounded-lg text-sm lg:text-base font-medium hover:bg-primary-dark transition-colors disabled:opacity-50"
>
등록
{mutation.isPending ? "등록중..." : "등록"}
</button>
</div>
</form>

View file

@ -1,39 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
import ParcelCard from "./ParcelCard";
import { Package } from "lucide-react";
function ParcelList({ parcels }) {
if (parcels.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16 text-gray-400"
>
<Package size={48} className="mx-auto mb-3 opacity-50" />
<p className="text-sm lg:text-base">등록된 택배가 없습니다</p>
</motion.div>
);
}
return (
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{parcels.map((parcel, index) => (
<motion.div
key={parcel.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -30 }}
transition={{ duration: 0.25, delay: index * 0.05 }}
>
<ParcelCard parcel={parcel} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export default ParcelList;

View file

@ -1,7 +1,18 @@
import { STATUS_MAP } from "@/data/dummy";
const STATUS_MAP = {
PENDING: { label: "대기중", color: "text-gray-500", bg: "bg-gray-100" },
UNKNOWN: { label: "알수없음", color: "text-gray-500", bg: "bg-gray-100" },
INFORMATION_RECEIVED: { label: "접수", color: "text-blue-600", bg: "bg-blue-50" },
AT_PICKUP: { label: "상품인수", color: "text-blue-600", bg: "bg-blue-50" },
IN_TRANSIT: { label: "이동중", color: "text-yellow-600", bg: "bg-yellow-50" },
OUT_FOR_DELIVERY: { label: "배달출발", color: "text-orange-600", bg: "bg-orange-50" },
DELIVERED: { label: "배달완료", color: "text-green-600", bg: "bg-green-50" },
AVAILABLE_FOR_PICKUP: { label: "수령가능", color: "text-purple-600", bg: "bg-purple-50" },
ATTEMPT_FAIL: { label: "배달실패", color: "text-red-600", bg: "bg-red-50" },
EXCEPTION: { label: "이상발생", color: "text-red-600", bg: "bg-red-50" },
};
function StatusBadge({ status }) {
const info = STATUS_MAP[status] || STATUS_MAP.PENDING;
const info = STATUS_MAP[status] || STATUS_MAP.UNKNOWN;
return (
<span

View file

@ -1,6 +1,13 @@
import { motion } from "framer-motion";
import dayjs from "dayjs";
import { STATUS_MAP } from "@/data/dummy";
const STATUS_COLORS = {
DELIVERED: "text-green-600",
OUT_FOR_DELIVERY: "text-orange-600",
IN_TRANSIT: "text-yellow-600",
AT_PICKUP: "text-blue-600",
INFORMATION_RECEIVED: "text-blue-600",
};
function TrackingTimeline({ events }) {
const reversed = [...events].reverse();
@ -10,11 +17,11 @@ function TrackingTimeline({ events }) {
{reversed.map((event, index) => {
const isFirst = index === 0;
const isLast = index === reversed.length - 1;
const statusInfo = STATUS_MAP[event.status] || STATUS_MAP.PENDING;
const statusColor = STATUS_COLORS[event.status] || "text-gray-500";
return (
<motion.div
key={index}
key={event.id || index}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.08 }}
@ -46,18 +53,22 @@ function TrackingTimeline({ events }) {
)}
</div>
<div className={`${isLast ? "pb-0" : "pb-5 lg:pb-6"} ${isFirst ? "" : "opacity-70"}`}>
<div
className={`${isLast ? "pb-0" : "pb-5 lg:pb-6"} ${isFirst ? "" : "opacity-70"}`}
>
<div className="flex items-center gap-2 mb-0.5 lg:mb-1">
<span
className={`text-xs lg:text-sm font-medium ${
isFirst ? statusInfo.color : "text-gray-500"
isFirst ? statusColor : "text-gray-500"
}`}
>
{event.statusName}
</span>
<span className="text-[11px] lg:text-xs text-gray-400">
{dayjs(event.time).format("MM.DD HH:mm")}
{event.status_name || event.status}
</span>
{event.event_time && (
<span className="text-[11px] lg:text-xs text-gray-400">
{dayjs(event.event_time).format("MM.DD HH:mm")}
</span>
)}
</div>
<p className="text-sm lg:text-base text-gray-700">
{event.description}

View file

@ -1,13 +1,26 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -1,138 +0,0 @@
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Trash2, RefreshCw, Pencil, Check, X } from "lucide-react";
import { useState } from "react";
import dayjs from "dayjs";
import useParcelStore from "@/stores/useParcelStore";
import TrackingTimeline from "@/components/TrackingTimeline";
import StatusBadge from "@/components/StatusBadge";
function DetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const parcel = useParcelStore((s) => s.getParcel(id));
const removeParcel = useParcelStore((s) => s.removeParcel);
const updateLabel = useParcelStore((s) => s.updateLabel);
const [editing, setEditing] = useState(false);
const [editLabel, setEditLabel] = useState("");
if (!parcel) {
return (
<div className="text-center py-12 text-gray-500">
<p className="lg:text-lg">택배를 찾을 없습니다</p>
<button
onClick={() => navigate("/")}
className="mt-4 text-primary hover:underline lg:text-lg"
>
돌아가기
</button>
</div>
);
}
const handleDelete = () => {
if (window.confirm("이 택배를 삭제할까요?")) {
removeParcel(parcel.id);
navigate("/");
}
};
const handleEditStart = () => {
setEditLabel(parcel.label || "");
setEditing(true);
};
const handleEditSave = () => {
updateLabel(parcel.id, editLabel);
setEditing(false);
};
return (
<div className="space-y-4 lg:space-y-5">
<div className="flex items-center justify-between">
<button
onClick={() => navigate("/")}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm lg:text-base"
>
<ArrowLeft size={16} className="lg:w-5 lg:h-5" />
목록으로
</button>
<button className="flex items-center gap-1 px-3 lg:px-4 py-1.5 lg:py-2 text-sm lg:text-base text-primary hover:bg-blue-50 rounded-lg transition-colors">
<RefreshCw size={14} className="lg:w-4 lg:h-4" />
새로고침
</button>
</div>
<div className="bg-white rounded-xl p-5 lg:p-6 shadow-sm space-y-4 lg:space-y-5">
<div className="flex items-start justify-between">
<div className="space-y-1 lg:space-y-2 flex-1 min-w-0">
{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 focus:outline-none focus:border-primary-dark placeholder:text-gray-300"
placeholder="별칭을 입력하세요"
autoFocus
/>
<button
onClick={handleEditSave}
className="text-green-600 hover:text-green-700 shrink-0 p-1"
>
<Check size={16} className="lg:w-[18px] lg:h-[18px]" />
</button>
<button
onClick={() => setEditing(false)}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<X size={16} className="lg:w-[18px] lg:h-[18px]" />
</button>
</div>
) : (
<div className="flex items-center gap-3 h-[28px] lg:h-[32px]">
<h2 className="font-semibold text-lg lg:text-xl truncate leading-none">
{parcel.label || parcel.trackingNumber}
</h2>
<button
onClick={handleEditStart}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<Pencil size={14} className="lg:w-4 lg:h-4" />
</button>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-500 shrink-0 p-1"
>
<Trash2 size={14} className="lg:w-4 lg:h-4" />
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm lg:text-base text-gray-500">
<span>{parcel.carrierName}</span>
<span className="text-gray-300">|</span>
<span className="tracking-letter-spacing">{parcel.trackingNumber}</span>
</div>
</div>
<StatusBadge status={parcel.status} />
</div>
<div className="pt-3 lg:pt-4 border-t border-gray-100">
<h3 className="font-semibold text-sm lg:text-base text-gray-700 mb-3 lg:mb-4">배송 추적</h3>
{parcel.events.length > 0 ? (
<TrackingTimeline events={parcel.events} />
) : (
<p className="text-sm lg:text-base text-gray-400 text-center py-4">
배송 정보가 아직 없습니다
</p>
)}
</div>
</div>
<div className="text-xs lg:text-sm text-gray-400 text-center">
마지막 조회: {dayjs(parcel.lastCheckedAt).format("YYYY.MM.DD HH:mm")}
</div>
</div>
);
}
export default DetailPage;

View file

@ -1,31 +1,94 @@
import { useState } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { AnimatePresence, motion } from "framer-motion";
import ParcelForm from "@/components/ParcelForm";
import ParcelList from "@/components/ParcelList";
import ParcelCard from "@/components/ParcelCard";
import ParcelDialog from "@/components/ParcelDialog";
import FilterTabs from "@/components/FilterTabs";
import useParcelStore from "@/stores/useParcelStore";
import { fetchParcels } from "@/api/parcels";
import { Loader2, Package } from "lucide-react";
const PAGE_LIMIT = 20;
function MainPage() {
const [showForm, setShowForm] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const filter = useParcelStore((s) => s.filter);
const setFilter = useParcelStore((s) => s.setFilter);
const parcels = useParcelStore((s) => s.filteredParcels());
const allParcels = useParcelStore((s) => s.parcels);
const scrollRef = useRef(null);
const activeCount = allParcels.filter((p) => p.status !== "DELIVERED").length;
const deliveredCount = allParcels.filter(
(p) => p.status === "DELIVERED"
).length;
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["parcels", filter],
queryFn: ({ pageParam = 1 }) =>
fetchParcels({ status: filter, page: pageParam, limit: PAGE_LIMIT }),
getNextPageParam: (lastPage) =>
lastPage.hasNextPage ? lastPage.pagination.page + 1 : undefined,
});
const parcels = data?.pages.flatMap((p) => p.data) || [];
const totalCount = data?.pages[0]?.pagination?.total || 0;
//
const { data: activeData } = useQuery({
queryKey: ["parcels-count-active"],
queryFn: () => fetchParcels({ status: "active", page: 1, limit: 1 }),
});
const { data: deliveredData } = useQuery({
queryKey: ["parcels-count-delivered"],
queryFn: () => fetchParcels({ status: "delivered", page: 1, limit: 1 }),
});
const { data: allData } = useQuery({
queryKey: ["parcels-count-all"],
queryFn: () => fetchParcels({ page: 1, limit: 1 }),
});
const allCount = allData?.pagination?.total || 0;
const activeCount = activeData?.pagination?.total || 0;
const deliveredCount = deliveredData?.pagination?.total || 0;
const virtualizer = useVirtualizer({
count: parcels.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 100,
overscan: 5,
gap: 12,
});
//
const lastItem = virtualizer.getVirtualItems().at(-1);
useEffect(() => {
if (!lastItem) return;
if (
lastItem.index >= parcels.length - 5 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [lastItem?.index, parcels.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleFilterChange = (f) => {
setFilter(f);
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="space-y-4 lg:space-y-5">
<div className="flex items-center justify-between">
<div className="flex flex-col h-full min-h-0">
<div className="flex-shrink-0 flex items-center justify-between mb-4">
<FilterTabs
filter={filter}
onFilterChange={setFilter}
onFilterChange={handleFilterChange}
activeCount={activeCount}
deliveredCount={deliveredCount}
totalCount={allParcels.length}
totalCount={allCount}
/>
<button
onClick={() => setShowForm(!showForm)}
@ -42,14 +105,67 @@ function MainPage() {
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
className="flex-shrink-0 overflow-hidden mb-4"
>
<ParcelForm onClose={() => setShowForm(false)} />
</motion.div>
)}
</AnimatePresence>
<ParcelList parcels={parcels} />
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-1 -mx-1 pb-2">
{isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
) : parcels.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Package size={48} className="mx-auto mb-3 opacity-50" />
<p className="text-sm lg:text-base">등록된 택배가 없습니다</p>
</div>
) : (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const parcel = parcels[virtualItem.index];
return (
<div
key={parcel.id}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ParcelCard
parcel={parcel}
onClick={() => setSelectedId(parcel.id)}
/>
</div>
);
})}
</div>
)}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<Loader2 className="animate-spin text-gray-400" size={24} />
</div>
)}
</div>
<ParcelDialog
parcelId={selectedId}
onClose={() => setSelectedId(null)}
/>
</div>
);
}

View file

@ -1,50 +1,8 @@
import { create } from "zustand";
import { DUMMY_PARCELS } from "@/data/dummy";
const useParcelStore = create((set, get) => ({
parcels: DUMMY_PARCELS,
filter: "all", // all, active, delivered
const useParcelStore = create((set) => ({
filter: "all",
setFilter: (filter) => set({ filter }),
filteredParcels: () => {
const { parcels, filter } = get();
if (filter === "active") {
return parcels.filter((p) => p.status !== "DELIVERED");
}
if (filter === "delivered") {
return parcels.filter((p) => p.status === "DELIVERED");
}
return parcels;
},
addParcel: (parcel) =>
set((state) => ({
parcels: [
{
id: Date.now(),
...parcel,
status: "PENDING",
lastDetail: "조회 대기중",
lastCheckedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
events: [],
},
...state.parcels,
],
})),
removeParcel: (id) =>
set((state) => ({
parcels: state.parcels.filter((p) => p.id !== id),
})),
updateLabel: (id, label) =>
set((state) => ({
parcels: state.parcels.map((p) => (p.id === id ? { ...p, label } : p)),
})),
getParcel: (id) => get().parcels.find((p) => p.id === Number(id)),
}));
export default useParcelStore;