feat: 일괄 삭제, 운송장 복사, 전역 토스트

- 삭제 모드 + 체크박스 일괄 삭제 (커스텀 체크박스 UI)
- 다이얼로그에서 운송장 번호 클릭 시 클립보드 복사
- 전역 토스트 시스템 (등록/삭제/복사 시 표시)
- 택배 카드 로고 크기 확대
- 삭제 버튼 스타일 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-24 20:29:13 +09:00
parent 6f89d8f0b2
commit 428cb99423
9 changed files with 275 additions and 28 deletions

View file

@ -151,6 +151,22 @@ export default async function parcelRoutes(fastify) {
return { success: true };
});
// 일괄 삭제
fastify.delete('/', async (request, reply) => {
const { ids } = request.body;
if (!Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: '삭제할 항목을 선택해주세요' });
}
const placeholders = ids.map(() => '?').join(',');
await fastify.db.execute(
`DELETE FROM parcels WHERE id IN (${placeholders})`,
ids.map(String)
);
return { success: true, deleted: ids.length };
});
// 수동 새로고침
fastify.post('/:id/refresh', async (request, reply) => {
const { id } = request.params;

View file

@ -1,6 +1,10 @@
import MainPage from "@/pages/MainPage";
import Toast from "@/components/Toast";
import useToastStore from "@/stores/useToastStore";
function App() {
const toastMessage = useToastStore((s) => s.message);
return (
<div className="h-screen overflow-hidden flex flex-col bg-secondary">
<header className="flex-shrink-0 bg-white border-b border-gray-200 z-10">
@ -16,6 +20,7 @@ function App() {
<MainPage />
</div>
</main>
<Toast message={toastMessage} />
</div>
);
}

View file

@ -49,6 +49,16 @@ export async function deleteParcel(id) {
return res.json();
}
export async function deleteParcels(ids) {
const res = await fetch(`${API}/parcels`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error('일괄 삭제 실패');
return res.json();
}
export async function refreshParcel(id) {
const res = await fetch(`${API}/parcels/${id}/refresh`, {
method: 'POST',

View file

@ -44,21 +44,21 @@ function ParcelCard({ parcel, onClick }) {
}
function CarrierLogo({ carrier }) {
if (!carrier) return <div className="w-7 h-7 lg:w-8 lg:h-8 shrink-0" />;
if (!carrier) return <div className="w-9 h-9 lg:w-10 lg:h-10 shrink-0" />;
if (carrier.logo_url) {
return (
<img
src={carrier.logo_url}
alt={carrier.name}
className="w-7 h-7 lg:w-8 lg:h-8 rounded-full object-contain shrink-0"
className="w-9 h-9 lg:w-10 lg:h-10 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"
className="inline-flex items-center justify-center w-9 h-9 lg:w-10 lg:h-10 rounded-full text-white text-[11px] lg:text-xs font-bold shrink-0"
style={{ backgroundColor: carrier.color }}
>
{carrier.short_name?.slice(0, 2)}

View file

@ -1,7 +1,8 @@
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 { X, Trash2, RefreshCw, Pencil, Check, Loader2, Copy } from "lucide-react";
import { useState } from "react";
import useToastStore from "@/stores/useToastStore";
import dayjs from "dayjs";
import {
fetchParcel,
@ -17,6 +18,7 @@ function ParcelDialog({ parcelId, onClose }) {
const [editing, setEditing] = useState(false);
const [editLabel, setEditLabel] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const showToast = useToastStore((s) => s.show);
const { data: parcel, isLoading } = useQuery({
queryKey: ["parcel", parcelId],
@ -28,6 +30,10 @@ function ParcelDialog({ parcelId, onClose }) {
mutationFn: () => deleteParcel(parcelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
showToast("택배가 삭제되었습니다");
onClose();
},
});
@ -150,9 +156,16 @@ function ParcelDialog({ parcelId, onClose }) {
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{parcel.carrier_name}</span>
<span className="text-gray-300">|</span>
<span className="tracking-letter-spacing">
<button
onClick={() => {
navigator.clipboard.writeText(parcel.tracking_number);
showToast("운송장 번호가 복사되었습니다");
}}
className="flex items-center gap-1 tracking-letter-spacing hover:text-primary transition-colors"
>
{parcel.tracking_number}
</span>
<Copy size={11} className="opacity-40" />
</button>
</div>
{(parcel.sender_name || parcel.recipient_name) && (
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5 flex-wrap">

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createParcel, fetchCarriers } from "@/api/parcels";
import CarrierSelect from "./CarrierSelect";
import useToastStore from "@/stores/useToastStore";
function ParcelForm({ onClose }) {
const queryClient = useQueryClient();
@ -10,10 +11,16 @@ function ParcelForm({ onClose }) {
const [label, setLabel] = useState("");
const [error, setError] = useState("");
const showToast = useToastStore((s) => s.show);
const mutation = useMutation({
mutationFn: createParcel,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
showToast("운송장이 등록되었습니다");
onClose();
},
onError: (err) => {

View file

@ -0,0 +1,23 @@
import { AnimatePresence, motion } from "framer-motion";
function Toast({ message }) {
return (
<AnimatePresence>
{message && (
<div className="fixed bottom-8 left-0 right-0 z-[80] flex justify-center pointer-events-none">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="bg-gray-800 text-white text-sm px-4 py-2.5 rounded-xl shadow-lg"
>
{message}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
export default Toast;

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useState, useRef, useEffect } from "react";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { AnimatePresence, motion } from "framer-motion";
import ParcelForm from "@/components/ParcelForm";
@ -7,17 +7,24 @@ 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";
import { fetchParcels, deleteParcels } from "@/api/parcels";
import useToastStore from "@/stores/useToastStore";
import { Loader2, Package, Trash2 } from "lucide-react";
const PAGE_LIMIT = 20;
function MainPage() {
const [showForm, setShowForm] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const [deleteMode, setDeleteMode] = useState(false);
const [checkedIds, setCheckedIds] = useState(new Set());
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const filter = useParcelStore((s) => s.filter);
const setFilter = useParcelStore((s) => s.setFilter);
const scrollRef = useRef(null);
const queryClient = useQueryClient();
const showToast = useToastStore((s) => s.show);
const {
data,
@ -34,9 +41,11 @@ function MainPage() {
});
const parcels = data?.pages.flatMap((p) => p.data) || [];
const totalCount = data?.pages[0]?.pagination?.total || 0;
//
const { data: allData } = useQuery({
queryKey: ["parcels-count-all"],
queryFn: () => fetchParcels({ page: 1, limit: 1 }),
});
const { data: activeData } = useQuery({
queryKey: ["parcels-count-active"],
queryFn: () => fetchParcels({ status: "active", page: 1, limit: 1 }),
@ -45,15 +54,25 @@ function MainPage() {
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 deleteMutation = useMutation({
mutationFn: (ids) => deleteParcels(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["parcels"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
showToast(`${checkedIds.size}건이 삭제되었습니다`);
setCheckedIds(new Set());
setDeleteMode(false);
setShowDeleteConfirm(false);
},
});
const virtualizer = useVirtualizer({
count: parcels.length,
getScrollElement: () => scrollRef.current,
@ -62,7 +81,6 @@ function MainPage() {
gap: 12,
});
//
const lastItem = virtualizer.getVirtualItems().at(-1);
useEffect(() => {
if (!lastItem) return;
@ -80,6 +98,28 @@ function MainPage() {
scrollRef.current?.scrollTo(0, 0);
};
const toggleCheck = (id) => {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (checkedIds.size === parcels.length) {
setCheckedIds(new Set());
} else {
setCheckedIds(new Set(parcels.map((p) => p.id)));
}
};
const exitDeleteMode = () => {
setDeleteMode(false);
setCheckedIds(new Set());
};
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-shrink-0 flex items-center justify-between mb-4">
@ -90,16 +130,52 @@ function MainPage() {
deliveredCount={deliveredCount}
totalCount={allCount}
/>
<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 className="flex items-center gap-2">
{deleteMode ? (
<>
<button
onClick={toggleAll}
className="px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-gray-500 hover:text-gray-700 transition-colors"
>
{checkedIds.size === parcels.length ? "선택해제" : "전체선택"}
</button>
<button
onClick={() => checkedIds.size > 0 && setShowDeleteConfirm(true)}
disabled={checkedIds.size === 0}
className="px-4 lg:px-5 py-2 lg:py-2.5 bg-red-500 text-white rounded-lg text-sm lg:text-base font-medium hover:bg-red-600 transition-colors disabled:opacity-40"
>
삭제 ({checkedIds.size})
</button>
<button
onClick={exitDeleteMode}
className="px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-gray-500 hover:text-gray-700 transition-colors"
>
취소
</button>
</>
) : (
<>
{parcels.length > 0 && (
<button
onClick={() => setDeleteMode(true)}
className="px-4 lg:px-5 py-[7px] lg:py-[9px] border border-red-300 text-red-500 rounded-lg text-sm lg:text-base font-medium hover:bg-red-50 transition-colors"
>
삭제
</button>
)}
<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>
</div>
<AnimatePresence>
{showForm && (
{showForm && !deleteMode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
@ -145,10 +221,43 @@ function MainPage() {
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ParcelCard
parcel={parcel}
onClick={() => setSelectedId(parcel.id)}
/>
<div className="flex items-center gap-2">
{deleteMode && (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: "auto" }}
className="shrink-0"
>
<div
onClick={(e) => {
e.stopPropagation();
toggleCheck(parcel.id);
}}
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center cursor-pointer transition-all ${
checkedIds.has(parcel.id)
? "bg-primary border-primary"
: "border-gray-300 hover:border-primary/50"
}`}
>
{checkedIds.has(parcel.id) && (
<svg viewBox="0 0 12 12" className="w-3 h-3 text-white" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 6l3 3 5-5" />
</svg>
)}
</div>
</motion.div>
)}
<div className="flex-1 min-w-0">
<ParcelCard
parcel={parcel}
onClick={() =>
deleteMode
? toggleCheck(parcel.id)
: setSelectedId(parcel.id)
}
/>
</div>
</div>
</div>
);
})}
@ -166,6 +275,53 @@ function MainPage() {
parcelId={selectedId}
onClose={() => setSelectedId(null)}
/>
{/* 일괄 삭제 확인 다이얼로그 */}
<AnimatePresence>
{showDeleteConfirm && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-[60]"
onClick={() => setShowDeleteConfirm(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-[70] flex items-center justify-center p-4"
onClick={() => setShowDeleteConfirm(false)}
>
<div
className="bg-white rounded-xl shadow-xl p-6 w-full max-w-xs space-y-4"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm lg:text-base text-gray-700 text-center">
{checkedIds.size}건의 택배를 삭제할까요?
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 px-4 py-2 text-sm text-gray-500 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
취소
</button>
<button
onClick={() => deleteMutation.mutate([...checkedIds])}
disabled={deleteMutation.isPending}
className="flex-1 px-4 py-2 text-sm text-white bg-red-500 rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
{deleteMutation.isPending ? "삭제중..." : "삭제"}
</button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { create } from "zustand";
let timeoutId = null;
const useToastStore = create((set) => ({
message: null,
show: (message, duration = 1500) => {
if (timeoutId) clearTimeout(timeoutId);
set({ message });
timeoutId = setTimeout(() => {
set({ message: null });
timeoutId = null;
}, duration);
},
}));
export default useToastStore;