Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 179 additions & 37 deletions src/app/(after-login)/workspace/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ 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';
Expand Down Expand Up @@ -43,19 +44,52 @@ export default function Page() {
const isMobile = useMediaQuery('(max-width: 767px)');
const pageSize = isMobile ? 5 : 6;

const storageKey = `workspace_page_${workspaceId}`;
const [page, setPage] = useState(() => {
if (typeof window === 'undefined') return 0;
const saved = sessionStorage.getItem(storageKey);
if (saved !== null) {
sessionStorage.removeItem(storageKey);
return Number(saved);
}
return 0;
});

const goToReceiptDetail = (receiptId: number) => {
sessionStorage.setItem(storageKey, String(page));
router.push(`/workspace/${workspaceId}/${receiptId}`);
};

const [storeName, setStoreName] = useState('');
const [selectedMemberName, setSelectedMemberName] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [selectedSort, setSelectedSort] = useState<string | null>(null);
const [page, setPage] = useState(0);

const [selectedReceiptIds, setSelectedReceiptIds] = useState<Set<number>>(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
Expand All @@ -64,10 +98,6 @@ export default function Page() {

const { data: statsData, isError: isStatsError } = useGetReceiptStats(workspaceId);

useEffect(() => {
setPage(0);
}, [storeName, selectedMemberName, selectedStatus, selectedSort]);

const listParams = {
workspaceId,
...(storeName ? { storeName } : {}),
Expand All @@ -80,6 +110,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('워크스페이스 정보를 불러오지 못했습니다.');
Expand All @@ -101,7 +133,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('다운로드가 완료되었습니다.');
Expand All @@ -110,6 +168,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);
Expand Down Expand Up @@ -148,30 +216,45 @@ export default function Page() {
</section>

<section className='flex flex-col gap-3 overflow-visible rounded-lg bg-white p-3.5 md:gap-5 md:p-4.5 lg:px-8.75 lg:py-6.5'>
<div className='flex items-center justify-between'>
<div className='flex items-start justify-between md:items-center'>
<h2 className='text-lg font-bold md:text-xl'>영수증 목록</h2>
<div className='flex gap-1.5 md:gap-2'>
{isAdmin && (
<div className='flex flex-col-reverse items-end gap-1.5 md:flex-row md:items-center md:gap-2'>
{isAdmin && selectedReceiptIds.size > 0 && (
<Button
group='sub'
variant='blue'
variant='white'
size='sm'
className='md:text-md w-17 md:h-8 md:w-17.5'
onClick={handleDownloadClick}
loading={isExporting}
className='md:text-md w-32.75 disabled:bg-white md:h-8'
onClick={handleDownloadSelected}
loading={isExportingSelected}
>
다운로드
선택 다운로드 ({selectedReceiptIds.size})
</Button>
)}
<Button
group='sub'
variant='blue'
size='sm'
className='md:text-md w-14.25 md:h-8 md:w-14.75'
onClick={() => router.push(`/workspace/${workspaceId}/upload`)}
>
업로드
</Button>

<div className='flex gap-1.5 md:gap-2'>
{isAdmin && (
<Button
group='sub'
variant='blue'
size='sm'
className='md:text-md w-17 md:h-8 md:w-17.5'
onClick={handleDownloadAll}
loading={isExporting}
>
다운로드
</Button>
)}
<Button
group='sub'
variant='blue'
size='sm'
className='md:text-md w-14.25 md:h-8 md:w-14.75'
onClick={() => router.push(`/workspace/${workspaceId}/upload`)}
>
업로드
</Button>
</div>
</div>
</div>

Expand Down Expand Up @@ -213,9 +296,38 @@ export default function Page() {
<div className='hidden md:block'>
<table className='w-full table-fixed text-sm md:text-base'>
<thead>
<tr className='text-black-200 border-b border-gray-200 text-left'>
<th className='w-[18%] pb-4 text-lg font-medium text-gray-400'>게시자</th>
<th className='w-[32%] pb-4 text-lg font-medium text-gray-400'>가맹점</th>
<tr className='text-black-200 border-b border-gray-200 text-left [&>th:first-child]:pl-1.5'>
{isAdmin && (
<th className='w-[5%] pb-4'>
<button
onClick={handleToggleAll}
className='flex cursor-pointer items-center'
aria-label='전체 선택'
>
{isAllSelected ? (
<Icon name='checkboxActive' size={18} className='overflow-visible' />
) : (
<Icon name='checkboxDefault' size={18} className='overflow-visible' />
)}
</button>
</th>
)}
<th
className={cn(
'pb-4 text-lg font-medium text-gray-400',
isAdmin ? 'w-[17%]' : 'w-[18%]',
)}
>
게시자
</th>
<th
className={cn(
'pb-4 text-lg font-medium text-gray-400',
isAdmin ? 'w-[28%]' : 'w-[32%]',
)}
>
가맹점
</th>
<th className='w-[16%] pb-4 text-lg font-medium text-gray-400'>금액</th>
<th className='w-[16%] pb-4 text-lg font-medium text-gray-400'>상태</th>
<th className='w-[18%] pb-4 text-lg font-medium text-gray-400'>게시일</th>
Expand All @@ -224,17 +336,32 @@ export default function Page() {
<tbody>
{receipts.length === 0 ? (
<tr>
<td colSpan={5} className='text-md py-10 text-center text-gray-400'>
<td colSpan={isAdmin ? 6 : 5} className='text-md py-10 text-center text-gray-400'>
영수증이 없습니다.
</td>
</tr>
) : (
receipts.map((receipt) => (
<tr
key={receipt.id}
className='text-black-200 cursor-pointer border-b border-gray-100 hover:bg-gray-50'
onClick={() => router.push(`/workspace/${workspaceId}/${receipt.id}`)}
className='text-black-200 cursor-pointer border-b border-gray-100 hover:bg-gray-50 [&>td:first-child]:pl-1.5'
onClick={() => goToReceiptDetail(receipt.id)}
>
{isAdmin && (
<td
className='py-3.5'
onClick={(e) => {
e.stopPropagation();
handleToggleReceipt(receipt.id);
}}
>
{selectedReceiptIds.has(receipt.id) ? (
<Icon name='checkboxActive' size={18} className='overflow-visible' />
) : (
<Icon name='checkboxDefault' size={18} className='overflow-visible' />
)}
</td>
)}
<td className='truncate py-3.5 pr-4'>{receipt.userName}</td>
<td className='truncate py-3.5 pr-4'>{receipt.storeName}</td>
<td className='py-3.5 pr-4'>₩{receipt.totalAmount.toLocaleString()}</td>
Expand All @@ -257,11 +384,26 @@ export default function Page() {
<div
key={receipt.id}
className={cn(
'flex cursor-pointer items-start gap-3 py-3.5',
'flex cursor-pointer items-start gap-3 px-2 py-3.5 hover:bg-gray-50',
idx !== receipts.length - 1 && 'border-b border-gray-100',
)}
onClick={() => router.push(`/workspace/${workspaceId}/${receipt.id}`)}
onClick={() => goToReceiptDetail(receipt.id)}
>
{isAdmin && (
<div
className='flex shrink-0 items-center pt-0.5'
onClick={(e) => {
e.stopPropagation();
handleToggleReceipt(receipt.id);
}}
>
{selectedReceiptIds.has(receipt.id) ? (
<Icon name='checkboxActive' size={16} className='mt-0.75 overflow-visible' />
) : (
<Icon name='checkboxDefault' size={16} className='mt-0.75 overflow-visible' />
)}
</div>
)}
<div className='flex flex-1 flex-col gap-1'>
<div className='flex items-center justify-between'>
<span className='text-black-200 text-md font-medium'>{receipt.storeName}</span>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Icon.paths.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ export const iconPaths = {
checkboxActive: (
<>
<rect x='0.5' y='0.5' width='23' height='23' rx='3.5' fill='#E8EEF7' />
<rect x='0.5' y='0.5' width='23' height='23' rx='3.5' stroke='#D9D9D9' />
<rect x='0.5' y='0.5' width='23' height='23' rx='3.5' stroke='#D9D9D9' fill='none' />
<path
d='M7 12L11 16L17 8'
stroke='#1251AA'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
fill='none'
/>
</>
),
Expand Down
Loading