From 458efe3e5d30ab52f944b0e4a0a794bd45546028 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 24 Mar 2026 19:36:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20API=20=EC=97=B0=EB=8F=99=20+=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20+=20=EB=AC=B4=ED=95=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 더미 데이터 → React Query + 실제 API 호출로 전환 - 상세 페이지 → 모달 다이얼로그로 변경 - 무한 스크롤 페이징 (useInfiniteQuery + react-virtual) - 내부 스크롤 적용 (전체 페이지 스크롤 제거) - 보내는 분/받는 분 정보 표시 - 삭제 확인 커스텀 다이얼로그 - DB 타임존 KST 설정 (dateStrings) - 백엔드 페이징 API 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/config/index.js | 2 + backend/src/plugins/db.js | 10 + backend/src/plugins/tracker.js | 4 + backend/src/routes/parcels.js | 41 ++- docs/plan.md | 6 +- frontend/package-lock.json | 55 ++++ frontend/package.json | 2 + frontend/src/App.jsx | 15 +- frontend/src/api/parcels.js | 66 +++++ frontend/src/components/CarrierBadge.jsx | 27 -- frontend/src/components/CarrierSelect.jsx | 40 ++- frontend/src/components/ParcelCard.jsx | 49 +++- frontend/src/components/ParcelDialog.jsx | 268 +++++++++++++++++++ frontend/src/components/ParcelForm.jsx | 52 ++-- frontend/src/components/ParcelList.jsx | 39 --- frontend/src/components/StatusBadge.jsx | 15 +- frontend/src/components/TrackingTimeline.jsx | 29 +- frontend/src/main.jsx | 19 +- frontend/src/pages/DetailPage.jsx | 138 ---------- frontend/src/pages/MainPage.jsx | 144 +++++++++- frontend/src/stores/useParcelStore.js | 46 +--- 21 files changed, 738 insertions(+), 329 deletions(-) create mode 100644 frontend/src/api/parcels.js delete mode 100644 frontend/src/components/CarrierBadge.jsx create mode 100644 frontend/src/components/ParcelDialog.jsx delete mode 100644 frontend/src/components/ParcelList.jsx delete mode 100644 frontend/src/pages/DetailPage.jsx diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 9af75dc..7ce9581 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -11,5 +11,7 @@ export default { database: process.env.DB_NAME || 'traeon', connectionLimit: 10, waitForConnections: true, + timezone: '+09:00', + dateStrings: true, }, }; diff --git a/backend/src/plugins/db.js b/backend/src/plugins/db.js index 236f661..427f81f 100644 --- a/backend/src/plugins/db.js +++ b/backend/src/plugins/db.js @@ -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( diff --git a/backend/src/plugins/tracker.js b/backend/src/plugins/tracker.js index bcc4782..92dfe6d 100644 --- a/backend/src/plugins/tracker.js +++ b/backend/src/plugins/tracker.js @@ -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, }; diff --git a/backend/src/routes/parcels.js b/backend/src/routes/parcels.js index 8c9133c..35f0abe 100644 --- a/backend/src/routes/parcels.js +++ b/backend/src/routes/parcels.js @@ -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] ); // 기존 이벤트 삭제 후 새로 삽입 diff --git a/docs/plan.md b/docs/plan.md index cd74c9a..960bc2c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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분 간격 자동 조회 - 배송 완료된 택배: 조회 중단 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 642efff..9c2f90c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 168cfa9..8f1d76f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 25ea8c4..19bc57e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,11 +1,9 @@ -import { Routes, Route } from "react-router-dom"; import MainPage from "@/pages/MainPage"; -import DetailPage from "@/pages/DetailPage"; function App() { return ( -
-
+
+
-
- - } /> - } /> - +
+
+ +
); diff --git a/frontend/src/api/parcels.js b/frontend/src/api/parcels.js new file mode 100644 index 0000000..0a488ea --- /dev/null +++ b/frontend/src/api/parcels.js @@ -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(); +} diff --git a/frontend/src/components/CarrierBadge.jsx b/frontend/src/components/CarrierBadge.jsx deleted file mode 100644 index cc02ff7..0000000 --- a/frontend/src/components/CarrierBadge.jsx +++ /dev/null @@ -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 ( - {carrier.name} - ); - } - - return ( - - {carrier.short.length > 2 ? carrier.short.slice(0, 2) : carrier.short} - - ); -} - -export default CarrierBadge; diff --git a/frontend/src/components/CarrierSelect.jsx b/frontend/src/components/CarrierSelect.jsx index 96df884..be5743f 100644 --- a/frontend/src/components/CarrierSelect.jsx +++ b/frontend/src/components/CarrierSelect.jsx @@ -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 ? ( <> - + {selected.name} ) : ( @@ -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) => (
  • @@ -83,4 +90,25 @@ function CarrierSelect({ value, onChange }) { ); } +function CarrierLogo({ carrier }) { + if (carrier.logo_url) { + return ( + {carrier.name} + ); + } + + return ( + + {carrier.short_name?.slice(0, 2)} + + ); +} + export default CarrierSelect; diff --git a/frontend/src/components/ParcelCard.jsx b/frontend/src/components/ParcelCard.jsx index cb0f7d1..7c0b1d6 100644 --- a/frontend/src/components/ParcelCard.jsx +++ b/frontend/src/components/ParcelCard.jsx @@ -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 (
    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" > - +
    - {parcel.carrierName} + {parcel.carrier_name} | - {parcel.trackingNumber} + {parcel.tracking_number}

    - {parcel.label || parcel.trackingNumber} + {parcel.label || parcel.tracking_number}

    - {dayjs(parcel.createdAt).format("YYYY.MM.DD")} + {dayjs(parcel.created_at).format("YYYY.MM.DD")}
    @@ -37,4 +43,27 @@ function ParcelCard({ parcel }) { ); } +function CarrierLogo({ carrier }) { + if (!carrier) return
    ; + + if (carrier.logo_url) { + return ( + {carrier.name} + ); + } + + return ( + + {carrier.short_name?.slice(0, 2)} + + ); +} + export default ParcelCard; diff --git a/frontend/src/components/ParcelDialog.jsx b/frontend/src/components/ParcelDialog.jsx new file mode 100644 index 0000000..0052ee6 --- /dev/null +++ b/frontend/src/components/ParcelDialog.jsx @@ -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 ( + <> + + {parcelId && ( + <> + + +
    e.stopPropagation()} + > + {isLoading || !parcel ? ( +
    + +
    + ) : ( + <> + {/* 헤더 */} +
    +
    +
    + {editing ? ( +
    + 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() + } + /> + + +
    + ) : ( +
    +

    + {parcel.label || parcel.tracking_number} +

    + + +
    + )} +
    + {parcel.carrier_name} + | + + {parcel.tracking_number} + +
    + {(parcel.sender_name || parcel.recipient_name) && ( +
    + {parcel.sender_name && ( + 보내는 분: {parcel.sender_name} + )} + {parcel.recipient_name && ( + 받는 분: {parcel.recipient_name} + )} +
    + )} +
    +
    + + +
    +
    +
    + + {/* 배송 추적 - 내부 스크롤 */} +
    +
    +
    +

    + 배송 추적 +

    + +
    + {parcel.last_checked_at && ( + + {dayjs(parcel.last_checked_at).format("MM.DD HH:mm")} 조회 + + )} +
    + {parcel.events?.length > 0 ? ( + + ) : ( +

    + 배송 정보가 아직 없습니다 +

    + )} +
    + + )} +
    +
    + + )} +
    + + + {showDeleteConfirm && ( + <> + setShowDeleteConfirm(false)} + /> + setShowDeleteConfirm(false)} + > +
    e.stopPropagation()} + > +

    + 이 택배를 삭제할까요? +

    +
    + + +
    +
    +
    + + )} +
    + + ); +} + +export default ParcelDialog; diff --git a/frontend/src/components/ParcelForm.jsx b/frontend/src/components/ParcelForm.jsx index 9034a98..aed36a3 100644 --- a/frontend/src/components/ParcelForm.jsx +++ b/frontend/src/components/ParcelForm.jsx @@ -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" > -

    운송장 등록

    +

    + 운송장 등록 +

    + + {error && ( +
    + {error} +
    + )}
    - +
    - +
    diff --git a/frontend/src/components/ParcelList.jsx b/frontend/src/components/ParcelList.jsx deleted file mode 100644 index 0b3c78c..0000000 --- a/frontend/src/components/ParcelList.jsx +++ /dev/null @@ -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 ( - - -

    등록된 택배가 없습니다

    -
    - ); - } - - return ( -
    - - {parcels.map((parcel, index) => ( - - - - ))} - -
    - ); -} - -export default ParcelList; diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx index 8ef3683..758e592 100644 --- a/frontend/src/components/StatusBadge.jsx +++ b/frontend/src/components/StatusBadge.jsx @@ -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 ( { 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 ( -
    +
    - {event.statusName} - - - {dayjs(event.time).format("MM.DD HH:mm")} + {event.status_name || event.status} + {event.event_time && ( + + {dayjs(event.event_time).format("MM.DD HH:mm")} + + )}

    {event.description} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index d719969..c506b7f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - - - + + + + + ); diff --git a/frontend/src/pages/DetailPage.jsx b/frontend/src/pages/DetailPage.jsx deleted file mode 100644 index 2231070..0000000 --- a/frontend/src/pages/DetailPage.jsx +++ /dev/null @@ -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 ( -

    -

    택배를 찾을 수 없습니다

    - -
    - ); - } - - 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 ( -
    -
    - - -
    - -
    -
    -
    - {editing ? ( -
    - 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 - /> - - -
    - ) : ( -
    -

    - {parcel.label || parcel.trackingNumber} -

    - - -
    - )} -
    - {parcel.carrierName} - | - {parcel.trackingNumber} -
    -
    - -
    - -
    -

    배송 추적

    - {parcel.events.length > 0 ? ( - - ) : ( -

    - 배송 정보가 아직 없습니다 -

    - )} -
    -
    - -
    - 마지막 조회: {dayjs(parcel.lastCheckedAt).format("YYYY.MM.DD HH:mm")} -
    -
    - ); -} - -export default DetailPage; diff --git a/frontend/src/pages/MainPage.jsx b/frontend/src/pages/MainPage.jsx index e1dac67..8879686 100644 --- a/frontend/src/pages/MainPage.jsx +++ b/frontend/src/pages/MainPage.jsx @@ -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 ( -
    -
    +
    +
    ); } diff --git a/frontend/src/stores/useParcelStore.js b/frontend/src/stores/useParcelStore.js index 33dd860..b0820ff 100644 --- a/frontend/src/stores/useParcelStore.js +++ b/frontend/src/stores/useParcelStore.js @@ -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;