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)
|
||||
);
|
||||
|
||||
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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
parcel_id INT NOT NULL,
|
||||
|
|
@ -107,7 +116,7 @@ CREATE TABLE tracking_events (
|
|||
| `PUT` | `/api/parcels/:id` | 별칭 수정 |
|
||||
| `DELETE` | `/api/parcels/:id` | 운송장 삭제 |
|
||||
| `POST` | `/api/parcels/:id/refresh` | 수동 새로고침 |
|
||||
| `GET` | `/api/carriers` | 지원 택배사 목록 |
|
||||
| `GET` | `/api/carriers` | 지원 택배사 목록 (로고 URL 포함) |
|
||||
|
||||
## 구현 단계
|
||||
|
||||
|
|
@ -121,24 +130,10 @@ CREATE TABLE tracking_events (
|
|||
- [ ] `.env` 파일 작성 (DB 접속정보 등)
|
||||
- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
|
||||
|
||||
### 2단계: Backend 구현
|
||||
|
||||
- [ ] 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 구현
|
||||
### 2단계: Frontend 구현 (더미 데이터)
|
||||
|
||||
- [ ] Vite + React + Tailwind 초기 세팅
|
||||
- [ ] Vite 프록시 설정 (`/api/` → backend)
|
||||
- [ ] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터)
|
||||
- [ ] 메인 페이지
|
||||
- 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭)
|
||||
- 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료)
|
||||
|
|
@ -147,8 +142,26 @@ CREATE TABLE tracking_events (
|
|||
- 배송 추적 타임라인 (세로 타임라인 UI)
|
||||
- 수동 새로고침 버튼
|
||||
- 삭제/수정 기능
|
||||
- [ ] React Query로 데이터 페칭 + 자동 리페칭
|
||||
- [ ] 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
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