feat: 프론트엔드 더미 데이터 UI 구현
- Docker Compose, Dockerfile, Vite 프로젝트 초기 세팅 - 메인 페이지: 택배 목록, 필터 탭, 운송장 등록 폼 - 상세 페이지: 배송 타임라인, 별칭 수정, 삭제 - 택배사 로고/컬러 배지, 커스텀 드롭다운 - framer-motion 애니메이션 적용 - PC/모바일 반응형 대응 - 계획서에 carriers 테이블, RustFS 로고 저장 반영 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fed454b8c9
commit
cf515aa1ee
28 changed files with 3844 additions and 18 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
traeon-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: traeon-frontend
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
external: true
|
||||||
49
docs/plan.md
49
docs/plan.md
|
|
@ -85,6 +85,15 @@ CREATE TABLE parcels (
|
||||||
UNIQUE KEY uq_carrier_tracking (carrier_id, tracking_number)
|
UNIQUE KEY uq_carrier_tracking (carrier_id, tracking_number)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE carriers (
|
||||||
|
id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: kr.cjlogistics)
|
||||||
|
name VARCHAR(100) NOT NULL, -- 택배사 이름
|
||||||
|
short_name VARCHAR(20) NOT NULL, -- 약칭 (로고 없을 때 이니셜용)
|
||||||
|
color VARCHAR(7) NOT NULL, -- 브랜드 컬러 (#hex)
|
||||||
|
logo_url VARCHAR(500), -- RustFS에 저장된 로고 이미지 URL
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE tracking_events (
|
CREATE TABLE tracking_events (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
parcel_id INT NOT NULL,
|
parcel_id INT NOT NULL,
|
||||||
|
|
@ -107,7 +116,7 @@ CREATE TABLE tracking_events (
|
||||||
| `PUT` | `/api/parcels/:id` | 별칭 수정 |
|
| `PUT` | `/api/parcels/:id` | 별칭 수정 |
|
||||||
| `DELETE` | `/api/parcels/:id` | 운송장 삭제 |
|
| `DELETE` | `/api/parcels/:id` | 운송장 삭제 |
|
||||||
| `POST` | `/api/parcels/:id/refresh` | 수동 새로고침 |
|
| `POST` | `/api/parcels/:id/refresh` | 수동 새로고침 |
|
||||||
| `GET` | `/api/carriers` | 지원 택배사 목록 |
|
| `GET` | `/api/carriers` | 지원 택배사 목록 (로고 URL 포함) |
|
||||||
|
|
||||||
## 구현 단계
|
## 구현 단계
|
||||||
|
|
||||||
|
|
@ -121,24 +130,10 @@ CREATE TABLE tracking_events (
|
||||||
- [ ] `.env` 파일 작성 (DB 접속정보 등)
|
- [ ] `.env` 파일 작성 (DB 접속정보 등)
|
||||||
- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
|
- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
|
||||||
|
|
||||||
### 2단계: Backend 구현
|
### 2단계: Frontend 구현 (더미 데이터)
|
||||||
|
|
||||||
- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
|
|
||||||
- [ ] DB 플러그인 + 테이블 자동 생성
|
|
||||||
- [ ] delivery-tracker GraphQL 클라이언트 플러그인
|
|
||||||
- [ ] API 라우트 구현 (위 API 설계 참고)
|
|
||||||
- [ ] 자동갱신 cron 서비스 (node-cron)
|
|
||||||
- 배송 중인 택배: 30분 간격 자동 조회
|
|
||||||
- 배송 완료된 택배: 조회 중단
|
|
||||||
- 상태 변경 감지 시 알림 트리거
|
|
||||||
- [ ] 알림 서비스
|
|
||||||
- Discord Webhook 또는 Telegram Bot API
|
|
||||||
- 설정 가능한 Webhook URL (.env)
|
|
||||||
|
|
||||||
### 3단계: Frontend 구현
|
|
||||||
|
|
||||||
- [ ] Vite + React + Tailwind 초기 세팅
|
- [ ] Vite + React + Tailwind 초기 세팅
|
||||||
- [ ] Vite 프록시 설정 (`/api/` → backend)
|
- [ ] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터)
|
||||||
- [ ] 메인 페이지
|
- [ ] 메인 페이지
|
||||||
- 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭)
|
- 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭)
|
||||||
- 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료)
|
- 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료)
|
||||||
|
|
@ -147,8 +142,26 @@ CREATE TABLE tracking_events (
|
||||||
- 배송 추적 타임라인 (세로 타임라인 UI)
|
- 배송 추적 타임라인 (세로 타임라인 UI)
|
||||||
- 수동 새로고침 버튼
|
- 수동 새로고침 버튼
|
||||||
- 삭제/수정 기능
|
- 삭제/수정 기능
|
||||||
- [ ] React Query로 데이터 페칭 + 자동 리페칭
|
|
||||||
- [ ] Zustand로 UI 상태 관리 (필터, 정렬)
|
- [ ] Zustand로 UI 상태 관리 (필터, 정렬)
|
||||||
|
- [ ] 디자인 확인 후 피드백 반영
|
||||||
|
|
||||||
|
### 3단계: Backend 구현 + 연동
|
||||||
|
|
||||||
|
- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
|
||||||
|
- [ ] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers)
|
||||||
|
- [ ] 택배사 로고 이미지를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장
|
||||||
|
- [ ] delivery-tracker GraphQL 클라이언트 플러그인
|
||||||
|
- [ ] API 라우트 구현 (위 API 설계 참고)
|
||||||
|
- [ ] Frontend를 더미 데이터에서 실제 API로 전환
|
||||||
|
- Vite 프록시 설정 (`/api/` → backend)
|
||||||
|
- React Query로 데이터 페칭 + 자동 리페칭
|
||||||
|
- [ ] 자동갱신 cron 서비스 (node-cron)
|
||||||
|
- 배송 중인 택배: 30분 간격 자동 조회
|
||||||
|
- 배송 완료된 택배: 조회 중단
|
||||||
|
- 상태 변경 감지 시 알림 트리거
|
||||||
|
- [ ] 알림 서비스
|
||||||
|
- Discord Webhook 또는 Telegram Bot API
|
||||||
|
- 설정 가능한 Webhook URL (.env)
|
||||||
|
|
||||||
### 4단계: Docker 배포
|
### 4단계: Docker 배포
|
||||||
|
|
||||||
|
|
|
||||||
4
frontend/Dockerfile
Normal file
4
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# 개발 모드
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]
|
||||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<title>Traeon - 택배 배송조회</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
as="style"
|
||||||
|
crossorigin
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2752
frontend/package-lock.json
generated
Normal file
2752
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "traeon-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"framer-motion": "^11.0.8",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.18",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
26
frontend/src/App.jsx
Normal file
26
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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="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>
|
||||||
|
<span className="text-sm lg:text-base text-gray-400">택배 배송조회</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
frontend/src/assets/carriers/hanjin.png
Normal file
BIN
frontend/src/assets/carriers/hanjin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/src/assets/carriers/logen.png
Normal file
BIN
frontend/src/assets/carriers/logen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/src/assets/carriers/lotte.png
Normal file
BIN
frontend/src/assets/carriers/lotte.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
27
frontend/src/components/CarrierBadge.jsx
Normal file
27
frontend/src/components/CarrierBadge.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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;
|
||||||
86
frontend/src/components/CarrierSelect.jsx
Normal file
86
frontend/src/components/CarrierSelect.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { CARRIERS } from "@/data/dummy";
|
||||||
|
import CarrierBadge from "./CarrierBadge";
|
||||||
|
|
||||||
|
function CarrierSelect({ value, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const selected = CARRIERS.find((c) => c.id === value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (carrierId) => {
|
||||||
|
onChange(carrierId);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={`w-full flex items-center gap-2.5 border rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left transition-colors ${
|
||||||
|
open
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-300 hover:border-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<CarrierBadge carrierId={selected.id} />
|
||||||
|
<span className="text-gray-900">{selected.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">택배사를 선택하세요</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`ml-auto text-gray-400 transition-transform duration-200 ${
|
||||||
|
open ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.ul
|
||||||
|
initial={{ opacity: 0, y: -8, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.97 }}
|
||||||
|
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) => (
|
||||||
|
<li key={carrier.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(carrier.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
value === carrier.id
|
||||||
|
? "bg-primary/5 text-primary"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CarrierBadge carrierId={carrier.id} />
|
||||||
|
<span>{carrier.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CarrierSelect;
|
||||||
34
frontend/src/components/FilterTabs.jsx
Normal file
34
frontend/src/components/FilterTabs.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
function FilterTabs({
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
activeCount,
|
||||||
|
deliveredCount,
|
||||||
|
totalCount,
|
||||||
|
}) {
|
||||||
|
const tabs = [
|
||||||
|
{ key: "all", label: "전체", count: totalCount },
|
||||||
|
{ key: "active", label: "배송중", count: activeCount },
|
||||||
|
{ key: "delivered", label: "완료", count: deliveredCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => onFilterChange(tab.key)}
|
||||||
|
className={`px-3 lg:px-4 py-1.5 lg:py-2 rounded-md text-xs lg:text-sm font-medium transition-colors ${
|
||||||
|
filter === tab.key
|
||||||
|
? "bg-white text-gray-900 shadow-sm"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span className="ml-1 text-[11px] lg:text-xs opacity-60">{tab.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterTabs;
|
||||||
40
frontend/src/components/ParcelCard.jsx
Normal file
40
frontend/src/components/ParcelCard.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import StatusBadge from "./StatusBadge";
|
||||||
|
import CarrierBadge from "./CarrierBadge";
|
||||||
|
|
||||||
|
function ParcelCard({ parcel }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/parcel/${parcel.id}`)}
|
||||||
|
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} />
|
||||||
|
|
||||||
|
<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>|</span>
|
||||||
|
<span className="tracking-letter-spacing">
|
||||||
|
{parcel.trackingNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-semibold text-sm lg:text-base text-gray-900 truncate">
|
||||||
|
{parcel.label || parcel.trackingNumber}
|
||||||
|
</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")}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={parcel.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParcelCard;
|
||||||
88
frontend/src/components/ParcelForm.jsx
Normal file
88
frontend/src/components/ParcelForm.jsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CARRIERS } from "@/data/dummy";
|
||||||
|
import useParcelStore from "@/stores/useParcelStore";
|
||||||
|
import CarrierSelect from "./CarrierSelect";
|
||||||
|
|
||||||
|
function ParcelForm({ onClose }) {
|
||||||
|
const addParcel = useParcelStore((s) => s.addParcel);
|
||||||
|
const [carrierId, setCarrierId] = useState("");
|
||||||
|
const [trackingNumber, setTrackingNumber] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!carrierId || !trackingNumber) return;
|
||||||
|
|
||||||
|
const carrier = CARRIERS.find((c) => c.id === carrierId);
|
||||||
|
addParcel({
|
||||||
|
carrierId,
|
||||||
|
carrierName: carrier.name,
|
||||||
|
trackingNumber: trackingNumber.replace(/\s/g, ""),
|
||||||
|
label: label || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCarrierId("");
|
||||||
|
setTrackingNumber("");
|
||||||
|
setLabel("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="space-y-3 lg:space-y-4">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={trackingNumber}
|
||||||
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||||
|
placeholder="운송장 번호를 입력하세요"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs lg:text-sm text-gray-500 mb-1">
|
||||||
|
별칭 (선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="예: 쿠팡 - 키보드"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 lg:px-5 py-2 lg:py-2.5 text-sm lg:text-base text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParcelForm;
|
||||||
39
frontend/src/components/ParcelList.jsx
Normal file
39
frontend/src/components/ParcelList.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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;
|
||||||
15
frontend/src/components/StatusBadge.jsx
Normal file
15
frontend/src/components/StatusBadge.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { STATUS_MAP } from "@/data/dummy";
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const info = STATUS_MAP[status] || STATUS_MAP.PENDING;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 lg:px-2.5 py-0.5 lg:py-1 rounded-full text-[11px] lg:text-xs font-medium ${info.bg} ${info.color}`}
|
||||||
|
>
|
||||||
|
{info.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
78
frontend/src/components/TrackingTimeline.jsx
Normal file
78
frontend/src/components/TrackingTimeline.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { STATUS_MAP } from "@/data/dummy";
|
||||||
|
|
||||||
|
function TrackingTimeline({ events }) {
|
||||||
|
const reversed = [...events].reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{reversed.map((event, index) => {
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const isLast = index === reversed.length - 1;
|
||||||
|
const statusInfo = STATUS_MAP[event.status] || STATUS_MAP.PENDING;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: -15 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.08 }}
|
||||||
|
className="flex gap-3 lg:gap-4 relative"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
delay: index * 0.08 + 0.1,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
}}
|
||||||
|
className={`w-3 h-3 lg:w-3.5 lg:h-3.5 rounded-full border-2 shrink-0 z-10 ${
|
||||||
|
isFirst
|
||||||
|
? "border-primary bg-primary"
|
||||||
|
: "border-gray-300 bg-white"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{!isLast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.08 + 0.15 }}
|
||||||
|
className="w-px flex-1 bg-gray-200 min-h-[40px] lg:min-h-[48px] origin-top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.statusName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] lg:text-xs text-gray-400">
|
||||||
|
{dayjs(event.time).format("MM.DD HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm lg:text-base text-gray-700">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-xs lg:text-sm text-gray-400 mt-0.5">
|
||||||
|
{event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackingTimeline;
|
||||||
198
frontend/src/data/dummy.js
Normal file
198
frontend/src/data/dummy.js
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
// 로고 이미지 import
|
||||||
|
import hanjinLogo from "@/assets/carriers/hanjin.png";
|
||||||
|
import lotteLogo from "@/assets/carriers/lotte.png";
|
||||||
|
import logenLogo from "@/assets/carriers/logen.png";
|
||||||
|
|
||||||
|
export const CARRIERS = [
|
||||||
|
{ id: "kr.cjlogistics", name: "CJ대한통운", short: "CJ", color: "#E4002B" },
|
||||||
|
{ id: "kr.hanjin", name: "한진택배", short: "한진", color: "#1B3A6B", logo: hanjinLogo },
|
||||||
|
{ id: "kr.lotte", name: "롯데택배", short: "롯데", color: "#ED1C24", logo: lotteLogo },
|
||||||
|
{ id: "kr.epost", name: "우체국택배", short: "우체국", color: "#003DA5" },
|
||||||
|
{ id: "kr.logen", name: "로젠택배", short: "로젠", color: "#F5A623", logo: logenLogo },
|
||||||
|
{ id: "kr.kdexp", name: "경동택배", short: "경동", color: "#0066B3" },
|
||||||
|
{ id: "kr.cupost", name: "CU편의점택배", short: "CU", color: "#652D90" },
|
||||||
|
{ id: "kr.daesin", name: "대신택배", short: "대신", color: "#00A651" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CARRIER_MAP = Object.fromEntries(
|
||||||
|
CARRIERS.map((c) => [c.id, c])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DUMMY_PARCELS = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
carrierId: "kr.cjlogistics",
|
||||||
|
carrierName: "CJ대한통운",
|
||||||
|
trackingNumber: "123456789012",
|
||||||
|
label: "쿠팡 - 키보드",
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
lastDetail: "경기 광주 Sub 터미널",
|
||||||
|
lastCheckedAt: dayjs().subtract(15, "minute").toISOString(),
|
||||||
|
createdAt: dayjs().subtract(2, "day").toISOString(),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
status: "ITEM_RECEIVED",
|
||||||
|
statusName: "상품인수",
|
||||||
|
description: "보내시는 고객님으로부터 상품을 인수했습니다",
|
||||||
|
location: "서울 강남 직영",
|
||||||
|
time: dayjs().subtract(2, "day").hour(14).minute(30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "상품이 이동중입니다",
|
||||||
|
location: "옥천 HUB",
|
||||||
|
time: dayjs().subtract(1, "day").hour(3).minute(15).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "배달 준비중입니다",
|
||||||
|
location: "경기 광주 Sub 터미널",
|
||||||
|
time: dayjs().subtract(0, "day").hour(6).minute(40).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
carrierId: "kr.hanjin",
|
||||||
|
carrierName: "한진택배",
|
||||||
|
trackingNumber: "987654321098",
|
||||||
|
label: "네이버 - 모니터 거치대",
|
||||||
|
status: "OUT_FOR_DELIVERY",
|
||||||
|
lastDetail: "성남시 분당 배달 출발",
|
||||||
|
lastCheckedAt: dayjs().subtract(5, "minute").toISOString(),
|
||||||
|
createdAt: dayjs().subtract(1, "day").toISOString(),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
status: "ITEM_RECEIVED",
|
||||||
|
statusName: "상품인수",
|
||||||
|
description: "상품을 인수했습니다",
|
||||||
|
location: "부산 사상",
|
||||||
|
time: dayjs().subtract(1, "day").hour(10).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "상품이 이동중입니다",
|
||||||
|
location: "대전 HUB",
|
||||||
|
time: dayjs().subtract(1, "day").hour(22).minute(30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "배송지 인근에 도착했습니다",
|
||||||
|
location: "성남시 분당",
|
||||||
|
time: dayjs().hour(7).minute(20).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "OUT_FOR_DELIVERY",
|
||||||
|
statusName: "배달출발",
|
||||||
|
description: "배달 출발했습니다",
|
||||||
|
location: "성남시 분당",
|
||||||
|
time: dayjs().hour(9).minute(10).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
carrierId: "kr.lotte",
|
||||||
|
carrierName: "롯데택배",
|
||||||
|
trackingNumber: "112233445566",
|
||||||
|
label: "11번가 - 충전기",
|
||||||
|
status: "DELIVERED",
|
||||||
|
lastDetail: "배달 완료",
|
||||||
|
lastCheckedAt: dayjs().subtract(1, "day").toISOString(),
|
||||||
|
deliveredAt: dayjs().subtract(1, "day").hour(14).minute(22).toISOString(),
|
||||||
|
createdAt: dayjs().subtract(3, "day").toISOString(),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
status: "ITEM_RECEIVED",
|
||||||
|
statusName: "상품인수",
|
||||||
|
description: "상품을 인수했습니다",
|
||||||
|
location: "서울 영등포",
|
||||||
|
time: dayjs().subtract(3, "day").hour(16).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "상품이 이동중입니다",
|
||||||
|
location: "대전 HUB",
|
||||||
|
time: dayjs().subtract(2, "day").hour(2).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "배송지 인근 도착",
|
||||||
|
location: "성남시 분당",
|
||||||
|
time: dayjs().subtract(1, "day").hour(7).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "OUT_FOR_DELIVERY",
|
||||||
|
statusName: "배달출발",
|
||||||
|
description: "배달 출발했습니다",
|
||||||
|
location: "성남시 분당",
|
||||||
|
time: dayjs().subtract(1, "day").hour(10).minute(30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "DELIVERED",
|
||||||
|
statusName: "배달완료",
|
||||||
|
description: "배달 완료되었습니다 (부재시 문앞 보관)",
|
||||||
|
location: "성남시 분당",
|
||||||
|
time: dayjs().subtract(1, "day").hour(14).minute(22).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
carrierId: "kr.epost",
|
||||||
|
carrierName: "우체국택배",
|
||||||
|
trackingNumber: "665544332211",
|
||||||
|
label: "알리 - USB 허브",
|
||||||
|
status: "DELIVERED",
|
||||||
|
lastDetail: "배달 완료",
|
||||||
|
lastCheckedAt: dayjs().subtract(5, "day").toISOString(),
|
||||||
|
deliveredAt: dayjs().subtract(5, "day").hour(11).minute(5).toISOString(),
|
||||||
|
createdAt: dayjs().subtract(8, "day").toISOString(),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
status: "ITEM_RECEIVED",
|
||||||
|
statusName: "접수",
|
||||||
|
description: "우체국에 접수되었습니다",
|
||||||
|
location: "인천국제우체국",
|
||||||
|
time: dayjs().subtract(8, "day").hour(9).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "발송되었습니다",
|
||||||
|
location: "인천국제우체국",
|
||||||
|
time: dayjs().subtract(7, "day").hour(18).minute(0).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
statusName: "이동중",
|
||||||
|
description: "도착했습니다",
|
||||||
|
location: "분당우체국",
|
||||||
|
time: dayjs().subtract(5, "day").hour(8).minute(30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "DELIVERED",
|
||||||
|
statusName: "배달완료",
|
||||||
|
description: "배달 완료",
|
||||||
|
location: "분당우체국",
|
||||||
|
time: dayjs().subtract(5, "day").hour(11).minute(5).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_MAP = {
|
||||||
|
PENDING: { label: "대기중", color: "text-gray-500", bg: "bg-gray-100" },
|
||||||
|
ITEM_RECEIVED: { 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" },
|
||||||
|
};
|
||||||
31
frontend/src/index.css
Normal file
31
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Pretendard", "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-letter-spacing {
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
138
frontend/src/pages/DetailPage.jsx
Normal file
138
frontend/src/pages/DetailPage.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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;
|
||||||
57
frontend/src/pages/MainPage.jsx
Normal file
57
frontend/src/pages/MainPage.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import ParcelForm from "@/components/ParcelForm";
|
||||||
|
import ParcelList from "@/components/ParcelList";
|
||||||
|
import FilterTabs from "@/components/FilterTabs";
|
||||||
|
import useParcelStore from "@/stores/useParcelStore";
|
||||||
|
|
||||||
|
function MainPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
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 activeCount = allParcels.filter((p) => p.status !== "DELIVERED").length;
|
||||||
|
const deliveredCount = allParcels.filter(
|
||||||
|
(p) => p.status === "DELIVERED"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 lg:space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FilterTabs
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
activeCount={activeCount}
|
||||||
|
deliveredCount={deliveredCount}
|
||||||
|
totalCount={allParcels.length}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{showForm ? "취소" : "+ 등록"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showForm && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<ParcelForm onClose={() => setShowForm(false)} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<ParcelList parcels={parcels} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainPage;
|
||||||
50
frontend/src/stores/useParcelStore.js
Normal file
50
frontend/src/stores/useParcelStore.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { DUMMY_PARCELS } from "@/data/dummy";
|
||||||
|
|
||||||
|
const useParcelStore = create((set, get) => ({
|
||||||
|
parcels: DUMMY_PARCELS,
|
||||||
|
filter: "all", // all, active, delivered
|
||||||
|
|
||||||
|
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;
|
||||||
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "#3B82F6",
|
||||||
|
dark: "#2563EB",
|
||||||
|
light: "#60A5FA",
|
||||||
|
},
|
||||||
|
secondary: "#F8FAFC",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Pretendard", "Inter", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 80,
|
||||||
|
allowedHosts: true,
|
||||||
|
hmr: {
|
||||||
|
overlay: false,
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://traeon-backend:80",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue