From 428cb994234e32f5576cbe7f8186b774bcdba342 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 24 Mar 2026 20:29:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B4=84=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?,=20=EC=9A=B4=EC=86=A1=EC=9E=A5=20=EB=B3=B5=EC=82=AC,=20?= =?UTF-8?q?=EC=A0=84=EC=97=AD=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 모드 + 체크박스 일괄 삭제 (커스텀 체크박스 UI) - 다이얼로그에서 운송장 번호 클릭 시 클립보드 복사 - 전역 토스트 시스템 (등록/삭제/복사 시 표시) - 택배 카드 로고 크기 확대 - 삭제 버튼 스타일 변경 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/routes/parcels.js | 16 ++ frontend/src/App.jsx | 5 + frontend/src/api/parcels.js | 10 ++ frontend/src/components/ParcelCard.jsx | 6 +- frontend/src/components/ParcelDialog.jsx | 19 ++- frontend/src/components/ParcelForm.jsx | 7 + frontend/src/components/Toast.jsx | 23 +++ frontend/src/pages/MainPage.jsx | 200 ++++++++++++++++++++--- frontend/src/stores/useToastStore.js | 17 ++ 9 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/Toast.jsx create mode 100644 frontend/src/stores/useToastStore.js diff --git a/backend/src/routes/parcels.js b/backend/src/routes/parcels.js index 8fb2983..843c0e9 100644 --- a/backend/src/routes/parcels.js +++ b/backend/src/routes/parcels.js @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 19bc57e..af61dcb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
@@ -16,6 +20,7 @@ function App() {
+ ); } diff --git a/frontend/src/api/parcels.js b/frontend/src/api/parcels.js index 0a488ea..b8c8aa2 100644 --- a/frontend/src/api/parcels.js +++ b/frontend/src/api/parcels.js @@ -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', diff --git a/frontend/src/components/ParcelCard.jsx b/frontend/src/components/ParcelCard.jsx index 7c0b1d6..343f515 100644 --- a/frontend/src/components/ParcelCard.jsx +++ b/frontend/src/components/ParcelCard.jsx @@ -44,21 +44,21 @@ function ParcelCard({ parcel, onClick }) { } function CarrierLogo({ carrier }) { - if (!carrier) return
; + if (!carrier) return
; if (carrier.logo_url) { return ( {carrier.name} ); } return ( {carrier.short_name?.slice(0, 2)} diff --git a/frontend/src/components/ParcelDialog.jsx b/frontend/src/components/ParcelDialog.jsx index 180617c..db9dc48 100644 --- a/frontend/src/components/ParcelDialog.jsx +++ b/frontend/src/components/ParcelDialog.jsx @@ -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 }) {
{parcel.carrier_name} | - +
{(parcel.sender_name || parcel.recipient_name) && (
diff --git a/frontend/src/components/ParcelForm.jsx b/frontend/src/components/ParcelForm.jsx index 87e9bef..4d0d7d0 100644 --- a/frontend/src/components/ParcelForm.jsx +++ b/frontend/src/components/ParcelForm.jsx @@ -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) => { diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 0000000..94effea --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -0,0 +1,23 @@ +import { AnimatePresence, motion } from "framer-motion"; + +function Toast({ message }) { + return ( + + {message && ( +
+ + {message} + +
+ )} +
+ ); +} + +export default Toast; diff --git a/frontend/src/pages/MainPage.jsx b/frontend/src/pages/MainPage.jsx index 8879686..1b959d8 100644 --- a/frontend/src/pages/MainPage.jsx +++ b/frontend/src/pages/MainPage.jsx @@ -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 (
@@ -90,16 +130,52 @@ function MainPage() { deliveredCount={deliveredCount} totalCount={allCount} /> - +
+ {deleteMode ? ( + <> + + + + + ) : ( + <> + {parcels.length > 0 && ( + + )} + + + )} +
- {showForm && ( + {showForm && !deleteMode && ( - setSelectedId(parcel.id)} - /> +
+ {deleteMode && ( + +
{ + 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) && ( + + + + )} +
+
+ )} +
+ + deleteMode + ? toggleCheck(parcel.id) + : setSelectedId(parcel.id) + } + /> +
+
); })} @@ -166,6 +275,53 @@ function MainPage() { parcelId={selectedId} onClose={() => setSelectedId(null)} /> + + {/* 일괄 삭제 확인 다이얼로그 */} + + {showDeleteConfirm && ( + <> + setShowDeleteConfirm(false)} + /> + setShowDeleteConfirm(false)} + > +
e.stopPropagation()} + > +

+ {checkedIds.size}건의 택배를 삭제할까요? +

+
+ + +
+
+
+ + )} +
); } diff --git a/frontend/src/stores/useToastStore.js b/frontend/src/stores/useToastStore.js new file mode 100644 index 0000000..9ab45b9 --- /dev/null +++ b/frontend/src/stores/useToastStore.js @@ -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;