From 21f23a1d07788b03b23d76bc38087765b5ea2927 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 28 May 2026 16:58:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 전용 영수증 선택 다운로드 기능 추가 (체크박스 UI, 전체 선택) - 게시자 드롭다운 멤버 목록 자동 갱신 (visibilitychange + 마운트 시 refetch) - 페이지네이션 위치 복원 (sessionStorage 활용) --- .../workspace/[workspaceId]/page.tsx | 214 +++++++++++++++--- 1 file changed, 180 insertions(+), 34 deletions(-) diff --git a/src/app/(after-login)/workspace/[workspaceId]/page.tsx b/src/app/(after-login)/workspace/[workspaceId]/page.tsx index 62cc85a..2fcf5c8 100644 --- a/src/app/(after-login)/workspace/[workspaceId]/page.tsx +++ b/src/app/(after-login)/workspace/[workspaceId]/page.tsx @@ -1,17 +1,18 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useParams, useRouter } from 'next/navigation'; import { useGetReceiptStats, useGetReceiptList, useExportReceipts, + useExportSelectedReceipts, } from '@/apis/receipt/receipt.queries'; -import { useGetWorkspace } from '@/apis/workspace/workspace.queries'; -import { useGetWorkspaceMembers } from '@/apis/workspace/workspace.queries'; +import { useGetWorkspace, useGetWorkspaceMembers } from '@/apis/workspace/workspace.queries'; import Button from '@/components/Buttons'; import Dropdown from '@/components/Dropdown'; +import Icon from '@/components/Icon'; import Input from '@/components/Input'; import StatCard from '@/components/StatCard'; import StatusChip from '@/components/StatusChip'; @@ -43,19 +44,51 @@ export default function Page() { const isMobile = useMediaQuery('(max-width: 767px)'); const pageSize = isMobile ? 5 : 6; + const storageKey = `workspace_page_${workspaceId}`; + const [page, setPageState] = useState(() => { + if (typeof window === 'undefined') return 0; + const saved = sessionStorage.getItem(storageKey); + return saved !== null ? Number(saved) : 0; + }); + + const setPage = useCallback( + (newPage: number) => { + setPageState(newPage); + sessionStorage.setItem(storageKey, String(newPage)); + }, + [storageKey], + ); + const [storeName, setStoreName] = useState(''); const [selectedMemberName, setSelectedMemberName] = useState(null); const [selectedStatus, setSelectedStatus] = useState(null); const [selectedSort, setSelectedSort] = useState(null); - const [page, setPage] = useState(0); + + const [selectedReceiptIds, setSelectedReceiptIds] = useState>(new Set()); const { data: workspaceData, isError: isWorkspaceError } = useGetWorkspace(workspaceId); const isAdmin = workspaceData?.data?.role === 'ADMIN'; - const { data: membersData, isError: isMembersError } = useGetWorkspaceMembers( - workspaceId, - 'ACCEPTED', - ); + const { + data: membersData, + isError: isMembersError, + refetch: refetchMembers, + } = useGetWorkspaceMembers(workspaceId, 'ACCEPTED'); + + useEffect(() => { + refetchMembers(); + }, [workspaceId, refetchMembers]); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refetchMembers(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [refetchMembers]); + const memberOptions = ['전체', ...(membersData?.data?.map((m) => m.name) ?? [])]; const selectedMember = selectedMemberName != null @@ -65,8 +98,9 @@ export default function Page() { const { data: statsData, isError: isStatsError } = useGetReceiptStats(workspaceId); useEffect(() => { - setPage(0); - }, [storeName, selectedMemberName, selectedStatus, selectedSort]); + const saved = sessionStorage.getItem(storageKey); + setPageState(saved !== null ? Number(saved) : 0); + }, [storageKey]); const listParams = { workspaceId, @@ -80,6 +114,8 @@ export default function Page() { const { data: listData, isError: isListError } = useGetReceiptList(listParams); const { mutateAsync: exportReceipts, isPending: isExporting } = useExportReceipts(); + const { mutateAsync: exportSelectedReceipts, isPending: isExportingSelected } = + useExportSelectedReceipts(); useEffect(() => { if (isWorkspaceError) toast.error('워크스페이스 정보를 불러오지 못했습니다.'); @@ -101,7 +137,33 @@ export default function Page() { const receipts = listData?.data ?? []; const totalPages = listData?.totalPages ?? 1; - const handleDownloadClick = async () => { + useEffect(() => { + setSelectedReceiptIds(new Set()); + }, [page, listData]); + + const isAllSelected = receipts.length > 0 && receipts.every((r) => selectedReceiptIds.has(r.id)); + + const handleToggleAll = () => { + if (isAllSelected) { + setSelectedReceiptIds(new Set()); + } else { + setSelectedReceiptIds(new Set(receipts.map((r) => r.id))); + } + }; + + const handleToggleReceipt = (id: number) => { + setSelectedReceiptIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleDownloadAll = async () => { try { await exportReceipts(workspaceId); toast.success('다운로드가 완료되었습니다.'); @@ -110,6 +172,16 @@ export default function Page() { } }; + const handleDownloadSelected = async () => { + try { + await exportSelectedReceipts({ workspaceId, receiptIds: Array.from(selectedReceiptIds) }); + toast.success('선택한 영수증 다운로드가 완료되었습니다.'); + setSelectedReceiptIds(new Set()); + } catch (e) { + toast.error(e instanceof Error ? e.message : '다운로드에 실패했습니다.'); + } + }; + const handlePageChange = (newPage: number) => { if (newPage < 0 || newPage >= totalPages) return; setPage(newPage); @@ -148,30 +220,45 @@ export default function Page() {
-
+

영수증 목록

-
- {isAdmin && ( +
+ {isAdmin && selectedReceiptIds.size > 0 && ( )} - + +
+ {isAdmin && ( + + )} + +
@@ -213,9 +300,38 @@ export default function Page() {
- - - + + {isAdmin && ( + + )} + + @@ -224,7 +340,7 @@ export default function Page() { {receipts.length === 0 ? ( - @@ -232,9 +348,24 @@ export default function Page() { receipts.map((receipt) => ( router.push(`/workspace/${workspaceId}/${receipt.id}`)} > + {isAdmin && ( + + )} @@ -257,11 +388,26 @@ export default function Page() {
router.push(`/workspace/${workspaceId}/${receipt.id}`)} > + {isAdmin && ( +
{ + e.stopPropagation(); + handleToggleReceipt(receipt.id); + }} + > + {selectedReceiptIds.has(receipt.id) ? ( + + ) : ( + + )} +
+ )}
{receipt.storeName} From ff524dd82b5779011a2341f4b3e418f28e6134a8 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 28 May 2026 17:11:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=92=84=20Design:=20checkboxActive=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20SVG=EC=97=90=20fill=3D'none'=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EA=B2=80=EC=9D=80=EC=83=89=20=EB=B0=B0=EA=B2=BD=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Icon.paths.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Icon.paths.tsx b/src/components/Icon.paths.tsx index 3f34210..e073b65 100644 --- a/src/components/Icon.paths.tsx +++ b/src/components/Icon.paths.tsx @@ -20,13 +20,14 @@ export const iconPaths = { checkboxActive: ( <> - + ), From 913b68fbdce3f7a3e1fb8227682ecc49a339d51d Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 28 May 2026 17:32:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=9C=84=EC=B9=98=EB=A5=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 워크스페이스 간 이동 시 이전 페이지 번호가 남아 다른 워크스페이스의 페이지네이션 상태를 오염시키던 버그 수정 - 평소 페이지 변경 시에는 스토리지에 저장하지 않고, 영수증 상세 페이지로 이동하는 직전에만 현재 페이지를 저장하도록 변경 - 컴포넌트 초기화 시 sessionStorage에서 값을 읽어온 후 즉시 삭제하여 다른 경로로 진입할 때는 0페이지(1페이지)로 초기화되도록 개선 --- .../workspace/[workspaceId]/page.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/app/(after-login)/workspace/[workspaceId]/page.tsx b/src/app/(after-login)/workspace/[workspaceId]/page.tsx index 2fcf5c8..0b155af 100644 --- a/src/app/(after-login)/workspace/[workspaceId]/page.tsx +++ b/src/app/(after-login)/workspace/[workspaceId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { useParams, useRouter } from 'next/navigation'; import { @@ -45,19 +45,20 @@ export default function Page() { const pageSize = isMobile ? 5 : 6; const storageKey = `workspace_page_${workspaceId}`; - const [page, setPageState] = useState(() => { + const [page, setPage] = useState(() => { if (typeof window === 'undefined') return 0; const saved = sessionStorage.getItem(storageKey); - return saved !== null ? Number(saved) : 0; + if (saved !== null) { + sessionStorage.removeItem(storageKey); + return Number(saved); + } + return 0; }); - const setPage = useCallback( - (newPage: number) => { - setPageState(newPage); - sessionStorage.setItem(storageKey, String(newPage)); - }, - [storageKey], - ); + const goToReceiptDetail = (receiptId: number) => { + sessionStorage.setItem(storageKey, String(page)); + router.push(`/workspace/${workspaceId}/${receiptId}`); + }; const [storeName, setStoreName] = useState(''); const [selectedMemberName, setSelectedMemberName] = useState(null); @@ -97,11 +98,6 @@ export default function Page() { const { data: statsData, isError: isStatsError } = useGetReceiptStats(workspaceId); - useEffect(() => { - const saved = sessionStorage.getItem(storageKey); - setPageState(saved !== null ? Number(saved) : 0); - }, [storageKey]); - const listParams = { workspaceId, ...(storeName ? { storeName } : {}), @@ -349,7 +345,7 @@ export default function Page() {
router.push(`/workspace/${workspaceId}/${receipt.id}`)} + onClick={() => goToReceiptDetail(receipt.id)} > {isAdmin && (
게시자가맹점
+ + + 게시자 + + 가맹점 + 금액 상태 게시일
+ 영수증이 없습니다.
{ + e.stopPropagation(); + handleToggleReceipt(receipt.id); + }} + > + {selectedReceiptIds.has(receipt.id) ? ( + + ) : ( + + )} + {receipt.userName} {receipt.storeName} ₩{receipt.totalAmount.toLocaleString()}
router.push(`/workspace/${workspaceId}/${receipt.id}`)} + onClick={() => goToReceiptDetail(receipt.id)} > {isAdmin && (
{selectedReceiptIds.has(receipt.id) ? ( - + ) : ( - + )}
)}