feat: 일괄 삭제, 운송장 복사, 전역 토스트
- 삭제 모드 + 체크박스 일괄 삭제 (커스텀 체크박스 UI) - 다이얼로그에서 운송장 번호 클릭 시 클립보드 복사 - 전역 토스트 시스템 (등록/삭제/복사 시 표시) - 택배 카드 로고 크기 확대 - 삭제 버튼 스타일 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f89d8f0b2
commit
428cb99423
9 changed files with 275 additions and 28 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
23
frontend/src/components/Toast.jsx
Normal file
23
frontend/src/components/Toast.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
17
frontend/src/stores/useToastStore.js
Normal file
17
frontend/src/stores/useToastStore.js
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue