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 };
|
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) => {
|
fastify.post('/:id/refresh', async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import MainPage from "@/pages/MainPage";
|
import MainPage from "@/pages/MainPage";
|
||||||
|
import Toast from "@/components/Toast";
|
||||||
|
import useToastStore from "@/stores/useToastStore";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const toastMessage = useToastStore((s) => s.message);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen overflow-hidden flex flex-col bg-secondary">
|
<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">
|
<header className="flex-shrink-0 bg-white border-b border-gray-200 z-10">
|
||||||
|
|
@ -16,6 +20,7 @@ function App() {
|
||||||
<MainPage />
|
<MainPage />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<Toast message={toastMessage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,16 @@ export async function deleteParcel(id) {
|
||||||
return res.json();
|
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) {
|
export async function refreshParcel(id) {
|
||||||
const res = await fetch(`${API}/parcels/${id}/refresh`, {
|
const res = await fetch(`${API}/parcels/${id}/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -44,21 +44,21 @@ function ParcelCard({ parcel, onClick }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CarrierLogo({ carrier }) {
|
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) {
|
if (carrier.logo_url) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={carrier.logo_url}
|
src={carrier.logo_url}
|
||||||
alt={carrier.name}
|
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 (
|
return (
|
||||||
<span
|
<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 }}
|
style={{ backgroundColor: carrier.color }}
|
||||||
>
|
>
|
||||||
{carrier.short_name?.slice(0, 2)}
|
{carrier.short_name?.slice(0, 2)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useState } from "react";
|
||||||
|
import useToastStore from "@/stores/useToastStore";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import {
|
import {
|
||||||
fetchParcel,
|
fetchParcel,
|
||||||
|
|
@ -17,6 +18,7 @@ function ParcelDialog({ parcelId, onClose }) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const showToast = useToastStore((s) => s.show);
|
||||||
|
|
||||||
const { data: parcel, isLoading } = useQuery({
|
const { data: parcel, isLoading } = useQuery({
|
||||||
queryKey: ["parcel", parcelId],
|
queryKey: ["parcel", parcelId],
|
||||||
|
|
@ -28,6 +30,10 @@ function ParcelDialog({ parcelId, onClose }) {
|
||||||
mutationFn: () => deleteParcel(parcelId),
|
mutationFn: () => deleteParcel(parcelId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["parcels"] });
|
queryClient.invalidateQueries({ queryKey: ["parcels"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
|
||||||
|
showToast("택배가 삭제되었습니다");
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -150,9 +156,16 @@ function ParcelDialog({ parcelId, onClose }) {
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<span>{parcel.carrier_name}</span>
|
<span>{parcel.carrier_name}</span>
|
||||||
<span className="text-gray-300">|</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}
|
{parcel.tracking_number}
|
||||||
</span>
|
<Copy size={11} className="opacity-40" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(parcel.sender_name || parcel.recipient_name) && (
|
{(parcel.sender_name || parcel.recipient_name) && (
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5 flex-wrap">
|
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createParcel, fetchCarriers } from "@/api/parcels";
|
import { createParcel, fetchCarriers } from "@/api/parcels";
|
||||||
import CarrierSelect from "./CarrierSelect";
|
import CarrierSelect from "./CarrierSelect";
|
||||||
|
import useToastStore from "@/stores/useToastStore";
|
||||||
|
|
||||||
function ParcelForm({ onClose }) {
|
function ParcelForm({ onClose }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -10,10 +11,16 @@ function ParcelForm({ onClose }) {
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const showToast = useToastStore((s) => s.show);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: createParcel,
|
mutationFn: createParcel,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["parcels"] });
|
queryClient.invalidateQueries({ queryKey: ["parcels"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-all"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-active"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["parcels-count-delivered"] });
|
||||||
|
showToast("운송장이 등록되었습니다");
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 { useState, useRef, useEffect } from "react";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import ParcelForm from "@/components/ParcelForm";
|
import ParcelForm from "@/components/ParcelForm";
|
||||||
|
|
@ -7,17 +7,24 @@ import ParcelCard from "@/components/ParcelCard";
|
||||||
import ParcelDialog from "@/components/ParcelDialog";
|
import ParcelDialog from "@/components/ParcelDialog";
|
||||||
import FilterTabs from "@/components/FilterTabs";
|
import FilterTabs from "@/components/FilterTabs";
|
||||||
import useParcelStore from "@/stores/useParcelStore";
|
import useParcelStore from "@/stores/useParcelStore";
|
||||||
import { fetchParcels } from "@/api/parcels";
|
import { fetchParcels, deleteParcels } from "@/api/parcels";
|
||||||
import { Loader2, Package } from "lucide-react";
|
import useToastStore from "@/stores/useToastStore";
|
||||||
|
import { Loader2, Package, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
const PAGE_LIMIT = 20;
|
const PAGE_LIMIT = 20;
|
||||||
|
|
||||||
function MainPage() {
|
function MainPage() {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
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 filter = useParcelStore((s) => s.filter);
|
||||||
const setFilter = useParcelStore((s) => s.setFilter);
|
const setFilter = useParcelStore((s) => s.setFilter);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const showToast = useToastStore((s) => s.show);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
|
@ -34,9 +41,11 @@ function MainPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const parcels = data?.pages.flatMap((p) => p.data) || [];
|
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({
|
const { data: activeData } = useQuery({
|
||||||
queryKey: ["parcels-count-active"],
|
queryKey: ["parcels-count-active"],
|
||||||
queryFn: () => fetchParcels({ status: "active", page: 1, limit: 1 }),
|
queryFn: () => fetchParcels({ status: "active", page: 1, limit: 1 }),
|
||||||
|
|
@ -45,15 +54,25 @@ function MainPage() {
|
||||||
queryKey: ["parcels-count-delivered"],
|
queryKey: ["parcels-count-delivered"],
|
||||||
queryFn: () => fetchParcels({ status: "delivered", page: 1, limit: 1 }),
|
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 allCount = allData?.pagination?.total || 0;
|
||||||
const activeCount = activeData?.pagination?.total || 0;
|
const activeCount = activeData?.pagination?.total || 0;
|
||||||
const deliveredCount = deliveredData?.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({
|
const virtualizer = useVirtualizer({
|
||||||
count: parcels.length,
|
count: parcels.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
|
|
@ -62,7 +81,6 @@ function MainPage() {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 무한 스크롤 감지
|
|
||||||
const lastItem = virtualizer.getVirtualItems().at(-1);
|
const lastItem = virtualizer.getVirtualItems().at(-1);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastItem) return;
|
if (!lastItem) return;
|
||||||
|
|
@ -80,6 +98,28 @@ function MainPage() {
|
||||||
scrollRef.current?.scrollTo(0, 0);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-shrink-0 flex items-center justify-between mb-4">
|
<div className="flex-shrink-0 flex items-center justify-between mb-4">
|
||||||
|
|
@ -90,16 +130,52 @@ function MainPage() {
|
||||||
deliveredCount={deliveredCount}
|
deliveredCount={deliveredCount}
|
||||||
totalCount={allCount}
|
totalCount={allCount}
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowForm(!showForm)}
|
{deleteMode ? (
|
||||||
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
|
||||||
{showForm ? "취소" : "+ 등록"}
|
onClick={toggleAll}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showForm && (
|
{showForm && !deleteMode && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
|
@ -145,10 +221,43 @@ function MainPage() {
|
||||||
transform: `translateY(${virtualItem.start}px)`,
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParcelCard
|
<div className="flex items-center gap-2">
|
||||||
parcel={parcel}
|
{deleteMode && (
|
||||||
onClick={() => setSelectedId(parcel.id)}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -166,6 +275,53 @@ function MainPage() {
|
||||||
parcelId={selectedId}
|
parcelId={selectedId}
|
||||||
onClose={() => setSelectedId(null)}
|
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>
|
</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