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;