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:
parent
d9ba70de16
commit
458efe3e5d
21 changed files with 738 additions and 329 deletions
|
|
@ -11,5 +11,7 @@ export default {
|
|||
database: process.env.DB_NAME || 'traeon',
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
timezone: '+09:00',
|
||||
dateStrings: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
// 기존 이벤트 삭제 후 새로 삽입
|
||||
|
|
|
|||
|
|
@ -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분 간격 자동 조회
|
||||
- 배송 완료된 택배: 조회 중단
|
||||
|
|
|
|||
55
frontend/package-lock.json
generated
55
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
66
frontend/src/api/parcels.js
Normal file
66
frontend/src/api/parcels.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
268
frontend/src/components/ParcelDialog.jsx
Normal file
268
frontend/src/components/ParcelDialog.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue