diff --git a/apps/admin/app/(auth)/(home)/page.tsx b/apps/admin/app/(auth)/(home)/page.tsx index dea8f502..3cca2dc6 100644 --- a/apps/admin/app/(auth)/(home)/page.tsx +++ b/apps/admin/app/(auth)/(home)/page.tsx @@ -7,7 +7,6 @@ import IconSession from '@/assets/icons/icon_session.png'; import IconSettlement from '@/assets/icons/icon_settlement.png'; import { NavigationBar } from '@/components/navigation-bar'; import { SESSION_ID } from '../(attendance)/attendance/search/@tabs/const/const'; -import { FeatureComingSoon } from './_components/coming-soon'; import { CurrentWeekSession } from './_components/current-week-session'; import { SessionBanner } from './_components/session-banner'; import { SessionCurrentWeekBanner } from './_components/session-current-week-banner'; @@ -39,9 +38,7 @@ const UserPage = () => { href={`/attendance/search/session?week=${SESSION_ID}`} /> - - - +
diff --git a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/attend/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/attend/page.tsx deleted file mode 100644 index d7b2c97e..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/attend/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import AttendTemplete from '@/components/settle/templates/AttendTemplate'; - -export default async function SettleDetailAttendPage() { - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/layout.tsx b/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/layout.tsx deleted file mode 100644 index 26e69095..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import DetailStateHeader from '@/components/settle/detail/DetailStateHeader'; - -const SettleDetailStateLayout = async ({ children }: { children: React.ReactNode }) => { - return ( - <> - - {children} - - ); -}; - -export default SettleDetailStateLayout; diff --git a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/submit/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/submit/page.tsx deleted file mode 100644 index e0e4c269..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/(state)/submit/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import SubmitTemplete from '@/components/settle/templates/SubmitTemplate'; - -export default async function SettleDetailSubmitPage() { - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/page.tsx deleted file mode 100644 index 31e6339f..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/[settleId]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import DetailTemplate from '@/components/settle/templates/DetailTemplate'; - -interface Props { - params: Promise<{ settleId: string }>; -} - -export default async function SettleDetailPage({ params }: Props) { - const { settleId } = await params; - - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-filter.tsx b/apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-filter.tsx deleted file mode 100644 index e3441b87..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-filter.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import { SettlementTypeFilter, SettlementTypeFilterItem } from './settlement-type-filter'; - -const SettlementFilter = () => { - return ( - - - - - - - ); -}; - -export { SettlementFilter }; diff --git a/apps/admin/app/(auth)/(settlement)/settlement/create/done/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/create/done/page.tsx deleted file mode 100644 index 33cc6a8e..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/create/done/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import DoneTemplate from '@/components/settle/templates/DoneTemplate'; - -export default async function SettleDoneCreatePage() { - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/create/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/create/page.tsx deleted file mode 100644 index d133d423..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/create/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import CreateTemplate from '@/components/settle/templates/CreateTemplate'; - -export default async function SettleCreatePage() { - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/layout.tsx b/apps/admin/app/(auth)/(settlement)/settlement/layout.tsx deleted file mode 100644 index f5b6dafb..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { AppLayout } from '@dpm-core/shared'; -import { AppHeader } from '@/components/app-header'; - -const SettleLayout = async ({ children }: { children: React.ReactNode }) => { - return ( - - - - {children} - - ); -}; - -export default SettleLayout; diff --git a/apps/admin/app/(auth)/(settlement)/settlement/page.tsx b/apps/admin/app/(auth)/(settlement)/settlement/page.tsx deleted file mode 100644 index 1e8418ec..00000000 --- a/apps/admin/app/(auth)/(settlement)/settlement/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import MainTemplete from '@/components/settle/templates/MainTemplete'; - -export default function SettleMainPage() { - return ( -
- -
- ); -} diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-item.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-item.tsx new file mode 100644 index 00000000..65093d55 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-item.tsx @@ -0,0 +1,24 @@ +import type { Part } from '@dpm-core/api'; +import { Profile } from '@/components/attendance/profile'; +import { formatPrice } from '../../../utils/formatPrice'; + +interface FinalAmountByMemberItemProps { + member: { + name: string; + teamNumber: number; + authority: string; + part: Exclude; + splitAmount: number; + }; +} +export const FinalAmountByMemberItem = ({ member }: FinalAmountByMemberItemProps) => { + return ( +
+ +

+ {formatPrice(member.splitAmount)} + +

+
+ ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-list.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-list.tsx new file mode 100644 index 00000000..cab968e0 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/_components/final-amount-by-member-list.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { getBillFinalAmountMemberByIdQueryOptions } from '@/remotes/queries/bill'; +import { FinalAmountByMemberItem } from './final-amount-by-member-item'; + +interface FinalAmountByMemberListProps { + billId: number; +} +export const FinalAmountByMemberList = ({ billId }: FinalAmountByMemberListProps) => { + const { + data: { data: billFinalAmountMemberList }, + } = useSuspenseQuery(getBillFinalAmountMemberByIdQueryOptions(billId)); + + return ( +
+
    + {billFinalAmountMemberList.members.map((member) => ( +
  • + +
  • + ))} +
+
+ ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/page.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/page.tsx new file mode 100644 index 00000000..32e16ef5 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/final-amount/page.tsx @@ -0,0 +1,22 @@ +import { AppHeader } from '@/components/app-header'; +import { FinalAmountByMemberList } from './_components/final-amount-by-member-list'; + +interface Props { + params: Promise<{ billId: string }>; +} + +export default async function BillFinalAmountByMemberPage({ params }: Props) { + const { billId } = await params; + + return ( + <> + +
+
+ 멤버별 정산 예정 금액입니다. 실제 입금 여부는 모임통장에서 확인해 주세요. +
+
+ + + ); +} diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submit-status-filter.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submit-status-filter.tsx new file mode 100644 index 00000000..e9079c0f --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submit-status-filter.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Tabs, TabsList, TabsTrigger } from '@dpm-core/shared'; +import { useBillSubmittedStatusSearchParams } from '../_hooks/use-bill-submitted-status-search-params'; + +const TABS_FILTER = [ + { + label: '제출', + value: 'SUBMIT', + }, + { + label: '미제출', + value: 'UNSUBMIT', + }, +]; + +export const SubmitStatusFilter = () => { + const { submitStatus, handleChange } = useBillSubmittedStatusSearchParams(); + + return ( + + + {TABS_FILTER.map(({ value, label }) => { + return ( + + {label} + + ); + })} + + + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-item.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-item.tsx new file mode 100644 index 00000000..a2f2565e --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-item.tsx @@ -0,0 +1,20 @@ +import type { Part } from '@dpm-core/api'; +import { Profile } from '@/components/attendance/profile'; + +interface SubmittedMemberItemProps { + member: { + name: string; + teamNumber: number; + authority: string; + part: Exclude; + isInvitationSubmitted: boolean; + }; +} + +export const SubmittedMemberItem = ({ member }: SubmittedMemberItemProps) => { + return ( +
+ +
+ ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-list.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-list.tsx new file mode 100644 index 00000000..7fa64b26 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_components/submitted-member-list.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { getBillSubmittedMemberByIdQueryOptions } from '@/remotes/queries/bill'; +import { useBillSubmittedStatusSearchParams } from '../_hooks/use-bill-submitted-status-search-params'; +import { SubmitStatusFilter } from './submit-status-filter'; +import { SubmittedMemberItem } from './submitted-member-item'; + +interface SubmittedMemberListProps { + billId: number; +} +export const SubmittedMemberList = ({ billId }: SubmittedMemberListProps) => { + const { + data: { data: submittedMemberList }, + } = useSuspenseQuery(getBillSubmittedMemberByIdQueryOptions(billId)); + + const { submitStatus } = useBillSubmittedStatusSearchParams(); + + const filteredMember = submittedMemberList.members.filter((member) => { + if (submitStatus === 'SUBMIT') { + return member.isInvitationSubmitted; + } + if (submitStatus === 'UNSUBMIT') { + return !member.isInvitationSubmitted; + } + return true; + }); + + return ( +
+ +
    + {filteredMember.map((member) => ( +
  • + +
  • + ))} +
+
+ ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_hooks/use-bill-submitted-status-search-params.ts b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_hooks/use-bill-submitted-status-search-params.ts new file mode 100644 index 00000000..4238ccaf --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/_hooks/use-bill-submitted-status-search-params.ts @@ -0,0 +1,20 @@ +import { useRouter, useSearchParams } from 'next/navigation'; + +import z from 'zod'; + +const submitStatusSchema = z.enum(['SUBMIT', 'UNSUBMIT']).catch('SUBMIT'); + +export const useBillSubmittedStatusSearchParams = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + + const submitStatus = submitStatusSchema.parse(searchParams.get('submitStatus')); + + const handleFilterSubmitStatus = (value: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set('submitStatus', value); + router.replace(`?${newSearchParams.toString()}`); + }; + + return { submitStatus, handleChange: handleFilterSubmitStatus }; +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/(status)/submit/page.tsx b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/page.tsx new file mode 100644 index 00000000..f087f1f8 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/(status)/submit/page.tsx @@ -0,0 +1,23 @@ +import { AppHeader } from '@/components/app-header'; +import { SubmittedMemberList } from './_components/submitted-member-list'; + +interface Props { + params: Promise<{ billId: string }>; +} + +export default async function BillMemberSubmitPage({ params }: Props) { + const { billId } = await params; + + return ( + <> + +
+
+ 초대 멤버 전원이 참석 여부를 제출하면 멤버를 확정할 수 있어요. +
+
+ + + + ); +} diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/bill-detail-container.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/bill-detail-container.tsx new file mode 100644 index 00000000..3ff3d01c --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/bill-detail-container.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { BillStatus } from '@dpm-core/api'; +import { ErrorBoundary } from '@suspensive/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Suspense } from 'react'; +import { AppHeader } from '@/components/app-header'; +import { LoadingBox } from '@/components/loading-box'; +import { getBillDetailByIdQueryOptions } from '@/remotes/queries/bill'; +import { BillFooterAction } from './bill-footer-action'; +import { BillInformation } from './bill-information'; +import { BillStatusInformation } from './bill-status'; +import { GatheringList } from './gathering-list'; + +const _BillDetailContainer = ({ billId }: { billId: number }) => { + const { + data: { data: bill }, + } = useSuspenseQuery(getBillDetailByIdQueryOptions(billId)); + + return ( + <> + +
+ + +
+
+
+ +
+ + + ); +}; + +export const BillDetailContainer = ErrorBoundary.with( + { fallback: <> }, + (props: { billId: number }) => ( + }> + <_BillDetailContainer billId={props.billId} /> + + ), +); + +const billHeaderTitle = (billStatus: BillStatus) => { + switch (billStatus) { + case 'OPEN': + return '정산서'; + case 'IN_PROGRESS': + return '최종 정산'; + case 'COMPLETED': + return '최종 정산서'; + } +}; + +const BillHeader = ({ billStatus }: { billStatus: BillStatus }) => { + return ; +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/bill-footer-action.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/bill-footer-action.tsx new file mode 100644 index 00000000..24a567f1 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/bill-footer-action.tsx @@ -0,0 +1,188 @@ +'use client'; + +import type { BillStatus } from '@dpm-core/api'; +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + useAppShell, +} from '@dpm-core/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { createPortal } from 'react-dom'; +import { closeBillParticipationMutationOptions } from '@/remotes/mutations/bill'; +import { getBillDetailByIdQueryOptions } from '@/remotes/queries/bill'; + +export const BillFooterAction = ({ + billStatus, + billId, +}: { + billStatus: BillStatus; + billId: number; +}) => { + const router = useRouter(); + const queryClient = useQueryClient(); + + const { mutate: closeBillParticipationMutate, isPending } = useMutation( + closeBillParticipationMutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries(getBillDetailByIdQueryOptions(billId)); + router.push(`/bills/${billId}/done`); + }, + onError: () => {}, + }), + ); + const handleCloseBillParticipation = () => { + closeBillParticipationMutate({ billId }); + }; + + // TODO : 정산 종료 API 추가 + switch (billStatus) { + case 'OPEN': + return ; + case 'IN_PROGRESS': + return ; + case 'COMPLETED': + return null; + default: + return null; + } +}; + +const OpenStatusButton = ({ disabled, ...props }: React.ComponentProps<'button'>) => { + const { ref } = useAppShell(); + + return createPortal( + + + + + + + 제출 전 확인 + + + + - + 멤버 확정 후에는 참석 여부를 수정 + 할 수 없습니다. + +
+ - 수정이 필요한 경우, 정산서를 삭제하고 새로 만들어야 합니다. +
+ - 확정 전에는 꼭 한 번 더 확인해 주세요. +
+ +
+ + + + +
+
+
+
, + ref.current, + ); +}; + +const InProgressStatusButton = ({ disabled, ...props }: React.ComponentProps<'button'>) => { + const { ref } = useAppShell(); + + return createPortal( + + + + + + + 정산 종료 전 확인 + + + - 정산이 종료되면 상태가 ‘정산 끝’으로 변경됩니다. +
+ + -{' '} + + 정산 종료 후에는 링크 복사 및 추가 편집이 불가능 + + 합니다. + +
+ - 정산 종료는 최종 확정입니다. 변경이 어려우니 꼭 다시 한 번 확인해 주세요. +
+ +
+ + + + +
+
+
+
, + ref.current, + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/bill-information.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/bill-information.tsx new file mode 100644 index 00000000..d755dcb6 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/bill-information.tsx @@ -0,0 +1,132 @@ +'use client'; + +import type { Bill } from '@dpm-core/api'; +import { Badge, Button, ChevronRight, CopyIcon, toast } from '@dpm-core/shared'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { formatISOStringToCompactYearDate } from '@/lib/date'; +import { getBillAccountbyId } from '@/remotes/queries/bill'; +import { CopyToClipBoard } from '../../create/[billId]/_components/copy-to-clipboard'; +import { formatAuthorityName } from '../utils/formatAuthorityName'; + +export const BillInformation = ({ bill }: { bill: Bill }) => { + const { + data: { data: billAccount }, + } = useSuspenseQuery(getBillAccountbyId(bill.billAccountId)); + + return ( +
+ {/* 회식 이름 */} +

{bill.title}

+ + {/* 회식 설명 */} + {bill.description && ( +

{bill.description}

+ )} + + {/* 회식 날짜, 범위 */} +
    +
  • +

    회식 날짜

    +

    + {formatISOStringToCompactYearDate(bill.gatherings[0].heldAt)} +

    +
  • +
  • +

    초대 범위

    +
    + {bill.inviteAuthorities.map((inviteAuthoritie) => ( + + @ + + {formatAuthorityName(inviteAuthoritie.authorityName)} + + + ))} +
    +
  • + + {/* 송금 계좌 */} + {bill.billStatus === 'IN_PROGRESS' && ( +
  • +

    송금 계좌

    +

    + {billAccount.billAccountValue} + {billAccount.bankName} + {billAccount.accountHolderName} +

    + toast.success('계좌번호를 복사했습니다.')} + > + + +
  • + )} +
+ + {/* 회식 인원 */} + {bill.billStatus === 'OPEN' ? ( + +
+ + icon + + + +
+

{bill.invitationSubmittedCount}

+ / +

{bill.invitedMemberCount}명 제출

+
+
+ + + ) : ( + +
+ + icon + + + +

멤버별 최종 금액

+
+ + + )} +
+ ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/bill-status.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/bill-status.tsx new file mode 100644 index 00000000..b419fab5 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/bill-status.tsx @@ -0,0 +1,179 @@ +'use client'; + +import type { BillStatus } from '@dpm-core/api'; +import { Button, toast } from '@dpm-core/shared'; +import { CopyToClipBoard } from '../../create/[billId]/_components/copy-to-clipboard'; + +interface Props { + billStatus: BillStatus; + billId: number; +} + +export const BillStatusInformation = ({ billStatus, billId }: Props) => { + const billLink = `${process.env.NEXT_PUBLIC_CLIENT_BASE_URL}/bills/${billId}`; + return ( +
+
+ +
+

+ +

+
+ {billStatus === 'IN_PROGRESS' && ( + toast.success('최종 정산 링크를 복사했습니다.')} + > + + + )} +
+ ); +}; + +const BillStatusBadge = ({ billStatus }: { billStatus: BillStatus }) => { + return ( +
+ {(() => { + switch (billStatus) { + case 'OPEN': + return ( + <> + + 멤버 확정 전 + + + + + + 멤버 확정 전 + + + ); + case 'IN_PROGRESS': + return ( + <> + + 정산 중 + + + + + + 정산 중 + + ); + case 'COMPLETED': + return ( + <> + + 정산 끝 + + + + 정산 끝 + + ); + default: + billStatus satisfies never; + return null; + } + })()} +
+ ); +}; + +const BillStatusDescription = ({ billStatus }: { billStatus: BillStatus }) => { + return ( + <> + {(() => { + switch (billStatus) { + case 'OPEN': + return ( + <> + 초대 멤버 전원이 참석 여부를 제출하면 +
+ 멤버를 확정할 수 있어요. + + ); + case 'IN_PROGRESS': + return ( + <> + 1. 링크를 복사해 공유하면, 멤버들이 각자 입금 금액을 바로 확인할 수 있어요. +
+ 2. 모든 멤버의 입금을 확인하셨다면, 정산을 종료해 주세요. + + ); + case 'COMPLETED': + return '정산이 종료된 회식입니다.'; + default: + billStatus satisfies never; + return null; + } + })()} + + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/gathering-item.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/gathering-item.tsx new file mode 100644 index 00000000..67c81154 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/gathering-item.tsx @@ -0,0 +1,128 @@ +import type { BillStatus, Gathering } from '@dpm-core/api'; +import { ChevronRight } from '@dpm-core/shared'; +import Link from 'next/link'; +import { formatPrice } from '../utils/formatPrice'; + +export const GatheringItem = ({ + billStatus, + gathering, + invitationSubmittedCount, +}: { + billStatus: BillStatus; + gathering: Gathering; + invitationSubmittedCount: number; +}) => { + return ( +
  • +

    #{gathering.title}

    +
    +
    + {/* 금액 */} + {billStatus === 'OPEN' ? ( +
    +

    금액

    +
    +

    총 {formatPrice(gathering.amount)}원

    +

    /인당 금액 미정

    +
    +
    + ) : ( +
    +

    금액

    +
    +

    + 인당 {formatPrice(gathering.splitAmount)}원 +

    +

    + /총 {formatPrice(gathering.amount)}원 +

    +
    +
    + )} + + {/* 인원 */} +
    +
    +

    참석

    +

    + {formatTwoDigits(gathering.joinMemberCount)}명 +

    +
    +
    +

    참석 안함

    +

    + {formatTwoDigits(invitationSubmittedCount - gathering.joinMemberCount)}명 +

    +
    +
    +
    + + {/* 참석자 리스트 확인 */} + + + {billStatus === 'OPEN' ? ( +
    + + icon + + + +

    참석자 리스트 확인

    +
    + ) : ( +
    + + icon + + + +
    +

    {formatTwoDigits(gathering.joinMemberCount)}

    + / +

    + {formatTwoDigits(invitationSubmittedCount)}명 참석 +

    +
    +
    + )} + + + +
    +
  • + ); +}; + +const formatTwoDigits = (value: number | string) => { + const num = typeof value === 'string' ? Number(value) : value; + if (Number.isNaN(num)) return ''; + + return String(num).padStart(2, '0'); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/_components/gathering-list.tsx b/apps/admin/app/(auth)/bills/[billId]/_components/gathering-list.tsx new file mode 100644 index 00000000..29353d80 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/_components/gathering-list.tsx @@ -0,0 +1,28 @@ +import type { BillStatus, Gathering } from '@dpm-core/api'; +import { GatheringItem } from './gathering-item'; + +export const GatheringList = ({ + billStatus, + gatherings, + invitationSubmittedCount, +}: { + billStatus: BillStatus; + gatherings: Gathering[]; + invitationSubmittedCount: number; +}) => { + return ( + <> +

    회식 참석 현황

    +
      + {gatherings.map((gathering) => ( + + ))} +
    + + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/done/_components/done-container.tsx b/apps/admin/app/(auth)/bills/[billId]/done/_components/done-container.tsx new file mode 100644 index 00000000..493483b6 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/done/_components/done-container.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Button, toast } from '@dpm-core/shared'; +import { CopyToClipBoard } from '../../../create/[billId]/_components/copy-to-clipboard'; + +interface DoneContainerProps { + billId: number; +} + +export const DoneContainer = (props: DoneContainerProps) => { + const { billId } = props; + const billLink = `${process.env.NEXT_PUBLIC_CLIENT_BASE_URL}/bills/${billId}`; + + return ( +
    +
    + + icon + + +
    +
    +

    최종 정산 링크 생성됨

    +

    + 최종 링크를 복사해 공유하면, +
    + 멤버들이 각자 입금 금액을 바로 확인할 수 있어요. +

    +
    + + toast.success('최종 정산 링크를 복사했습니다.')} + > + + +
    +
    +
    + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/done/_components/floating-button-cotainer.tsx b/apps/admin/app/(auth)/bills/[billId]/done/_components/floating-button-cotainer.tsx new file mode 100644 index 00000000..f562d135 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/done/_components/floating-button-cotainer.tsx @@ -0,0 +1,16 @@ +import { Button } from '@dpm-core/shared'; +import Link from 'next/link'; + +interface FloatingButtonContainerProps { + billId: number; +} + +export const FloatingButtonContainer = (props: FloatingButtonContainerProps) => { + const { billId } = props; + + return ( + + ); +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/done/page.tsx b/apps/admin/app/(auth)/bills/[billId]/done/page.tsx new file mode 100644 index 00000000..5df98997 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/done/page.tsx @@ -0,0 +1,18 @@ +import { AppHeader } from '@/components/app-header'; +import { DoneContainer } from './_components/done-container'; +import { FloatingButtonContainer } from './_components/floating-button-cotainer'; + +interface Props { + params: Promise<{ billId: string }>; +} +export default async function BillStatusDonePage({ params }: Props) { + const { billId } = await params; + + return ( + <> + + + + + ); +} diff --git a/apps/admin/app/(auth)/bills/[billId]/page.tsx b/apps/admin/app/(auth)/bills/[billId]/page.tsx new file mode 100644 index 00000000..f73362f9 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/page.tsx @@ -0,0 +1,10 @@ +import { BillDetailContainer } from './_components/bill-detail-container'; + +interface Props { + params: Promise<{ billId: string }>; +} +export default async function BillsDetailPage({ params }: Props) { + const { billId } = await params; + + return ; +} diff --git a/apps/admin/app/(auth)/bills/[billId]/utils/formatAuthorityName.ts b/apps/admin/app/(auth)/bills/[billId]/utils/formatAuthorityName.ts new file mode 100644 index 00000000..c6723dfe --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/utils/formatAuthorityName.ts @@ -0,0 +1,17 @@ +type Authority = 'DEEPER' | 'ORGANIZER'; + +const AuthorityLabelMap: Record = { + DEEPER: '디퍼', + ORGANIZER: '운영진', +}; + +export const formatAuthorityName = (value: string) => { + if (!value) return ''; + + const [generation, authority] = value.split('_') as [string, Authority]; + + const generationLabel = `${generation}기`; + const authorityLabel = AuthorityLabelMap[authority] ?? authority; + + return `${generationLabel} ${authorityLabel}`; +}; diff --git a/apps/admin/app/(auth)/bills/[billId]/utils/formatPrice.ts b/apps/admin/app/(auth)/bills/[billId]/utils/formatPrice.ts new file mode 100644 index 00000000..053ad841 --- /dev/null +++ b/apps/admin/app/(auth)/bills/[billId]/utils/formatPrice.ts @@ -0,0 +1,3 @@ +export const formatPrice = (num: number) => { + return num.toLocaleString('ko-KR'); +}; diff --git a/apps/admin/app/(auth)/bills/_components/bill-filter.tsx b/apps/admin/app/(auth)/bills/_components/bill-filter.tsx new file mode 100644 index 00000000..30868c4e --- /dev/null +++ b/apps/admin/app/(auth)/bills/_components/bill-filter.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useBillStatusSearchParams } from '../_hooks/use-bill-status-search-params'; +import { BillTypeFilter, BillTypeFilterItem } from './bill-type-filter'; + +const BillFilter = () => { + const { billStatus, handleChange } = useBillStatusSearchParams(); + + return ( + handleChange(filterValue)} + className="flex gap-x-2 py-2.5 px-4" + value={billStatus} + > + + + + + + ); +}; + +export { BillFilter }; diff --git a/apps/admin/app/(auth)/bills/_components/bill-item.tsx b/apps/admin/app/(auth)/bills/_components/bill-item.tsx new file mode 100644 index 00000000..659fb634 --- /dev/null +++ b/apps/admin/app/(auth)/bills/_components/bill-item.tsx @@ -0,0 +1,197 @@ +import type { Bill, BillStatus } from '@dpm-core/api'; +import Link from 'next/link'; + +function formatInvitationCount( + invitedMemberCount: number, + invitationSubmittedCount: number, + billStatus: BillStatus, +) { + switch (billStatus) { + case 'OPEN': + return `${invitationSubmittedCount}/${invitedMemberCount}명 제출`; + case 'IN_PROGRESS': + case 'COMPLETED': + return `대상자 ${invitedMemberCount}명`; + } +} + +function BillItem({ bill }: { bill: Bill }) { + return ( +
  • + + +
    +

    {bill.title}

    +
    +
    + + calendar-01 + + + + {bill.title} + +
    +
    + + bill-01 + + + + + ~{bill.gatherings.length}차 + +
    + + user-01 + + + + + {formatInvitationCount( + bill.invitedMemberCount, + bill.invitationSubmittedCount, + bill.billStatus, + )} + +
    +
    +
    + +
  • + ); +} + +function BillStatusBadge({ status }: { status: BillStatus }) { + return ( +
    + {(() => { + switch (status) { + case 'OPEN': + return ( + <> + + 멤버 확정 전 + + + + + + 멤버 확정 전 + + + ); + case 'IN_PROGRESS': + return ( + <> + + 정산 중 + + + + + + 정산 중 + + ); + case 'COMPLETED': + return ( + <> + + 정산 끝 + + + + 정산 끝 + + ); + default: + status satisfies never; + return null; + } + })()} +
    + ); +} + +export { BillItem }; diff --git a/apps/admin/app/(auth)/bills/_components/bill-list.tsx b/apps/admin/app/(auth)/bills/_components/bill-list.tsx new file mode 100644 index 00000000..6287e1eb --- /dev/null +++ b/apps/admin/app/(auth)/bills/_components/bill-list.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { Button, useAppShell } from '@dpm-core/shared'; +import { ErrorBoundary } from '@suspensive/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { Suspense } from 'react'; +import { createPortal } from 'react-dom'; +import { ErrorBox } from '@/components/error-box'; +import { LoadingBox } from '@/components/loading-box'; +import { getBillsQueryOptions } from '@/remotes/queries/bill'; +import { useBillStatusSearchParams } from '../_hooks/use-bill-status-search-params'; +import { BillFilter } from './bill-filter'; +import { BillItem } from './bill-item'; + +const BillListContainer = () => { + const { billStatus } = useBillStatusSearchParams(); + const { + data: { + data: { bills }, + }, + } = useSuspenseQuery(getBillsQueryOptions); + + const filteredBillsByStatus = + billStatus === 'ALL' ? bills : bills.filter((bill) => bill.billStatus === billStatus); + + return ( + <> + + {filteredBillsByStatus.length > 0 ? ( +
      + {filteredBillsByStatus.map((bill) => { + return ; + })} +
    + ) : ( +
    + + bill-01 + + + +

    아직 정산서가 없어요.

    +
    + )} + + + ); +}; + +const BillList = ErrorBoundary.with( + { + fallback: (props) => props.reset()} />, + }, + () => ( + }> + + + ), +); + +export { BillList }; + +function AddButton() { + const { ref } = useAppShell(); + + const router = useRouter(); + const handleCreateSettle = () => { + router.push('/bills/create'); + }; + return createPortal( + , + ref.current, + ); +} diff --git a/apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-type-filter.tsx b/apps/admin/app/(auth)/bills/_components/bill-type-filter.tsx similarity index 59% rename from apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-type-filter.tsx rename to apps/admin/app/(auth)/bills/_components/bill-type-filter.tsx index 9d6a9ea0..55ba7b8f 100644 --- a/apps/admin/app/(auth)/(settlement)/settlement/_components/settlement-type-filter.tsx +++ b/apps/admin/app/(auth)/bills/_components/bill-type-filter.tsx @@ -3,31 +3,34 @@ import { cn, createContext } from '@dpm-core/shared'; import { type ComponentPropsWithoutRef, type PropsWithChildren, useState } from 'react'; -interface SettlementTypeFilterProps { +interface BillTypeFilterProps { value?: T; defaultValue?: T; onChange?: (value: T) => void; } -interface SettlementTypeFilterItemProps { +interface BillTypeFilterItemProps { value: T; label?: string; } -const [SettlementTypeFilterProvider, useSettlementTypeFilter] = - createContext('settlement-type-filter', { +const [BillTypeFilterProvider, useBillTypeFilter] = createContext( + 'Bill-type-filter', + { value: undefined, defaultValue: undefined, onChange: undefined, - }); + }, +); -const SettlementTypeFilter = ({ +const BillTypeFilter = ({ value, defaultValue, onChange, children, ...rest -}: PropsWithChildren> & ComponentPropsWithoutRef<'div'>) => { +}: PropsWithChildren> & + Omit, 'onChange'>) => { const [internalValue, setInternalValue] = useState(defaultValue as T); const isControlled = value !== undefined; @@ -40,20 +43,14 @@ const SettlementTypeFilter = ({ onChange?.(newValue); }; return ( - void} - > + void}>
    {children}
    -
    + ); }; -const SettlementTypeFilterItem = ({ - value, - label, -}: SettlementTypeFilterItemProps) => { - const { value: currentValue, onChange } = useSettlementTypeFilter(); +const BillTypeFilterItem = ({ value, label }: BillTypeFilterItemProps) => { + const { value: currentValue, onChange } = useBillTypeFilter(); const isSelected = currentValue === value; return ( + +
    +
    +
    + ); +}; diff --git a/apps/admin/app/(auth)/bills/create/[billId]/_components/floating-button-container.tsx b/apps/admin/app/(auth)/bills/create/[billId]/_components/floating-button-container.tsx new file mode 100644 index 00000000..0856160b --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/[billId]/_components/floating-button-container.tsx @@ -0,0 +1,16 @@ +import { Button } from '@dpm-core/shared'; +import Link from 'next/link'; + +interface FloatingButtonContainerProps { + billId: number; +} + +export const FloatingButtonContainer = (props: FloatingButtonContainerProps) => { + const { billId } = props; + + return ( + + ); +}; diff --git a/apps/admin/app/(auth)/bills/create/[billId]/page.tsx b/apps/admin/app/(auth)/bills/create/[billId]/page.tsx new file mode 100644 index 00000000..b2f25d91 --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/[billId]/page.tsx @@ -0,0 +1,19 @@ +import { AppHeader } from '@/components/app-header'; +import { DoneContainer } from './_components/done-container'; +import { FloatingButtonContainer } from './_components/floating-button-container'; + +interface CreateBillsDonePageProps { + params: Promise<{ billId: string }>; +} + +export default async function CreateBillsDonePage({ params }: CreateBillsDonePageProps) { + const { billId } = await params; + + return ( + <> + + + + + ); +} diff --git a/apps/admin/app/(auth)/bills/create/_components/bill-create-button.tsx b/apps/admin/app/(auth)/bills/create/_components/bill-create-button.tsx new file mode 100644 index 00000000..7e5caba8 --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/_components/bill-create-button.tsx @@ -0,0 +1,65 @@ +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + useAppShell, +} from '@dpm-core/shared'; +import { createPortal } from 'react-dom'; + +export const BillCreateButton = ({ disabled, ...props }: React.ComponentProps<'button'>) => { + const { ref } = useAppShell(); + + return createPortal( + + + + + + + 생성 전 확인 + + + 정산서를 생성하시겠어요? MVP 단계에서는 생성 후 수정이
    어려우니, 한 번 더 확인해 + 주세요. +
    + +
    + + + + +
    +
    +
    +
    , + ref.current, + ); +}; diff --git a/apps/admin/app/(auth)/bills/create/_components/bill-form.tsx b/apps/admin/app/(auth)/bills/create/_components/bill-form.tsx new file mode 100644 index 00000000..3556854a --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/_components/bill-form.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Divider, Form } from '@dpm-core/shared'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import z from 'zod'; + +import { createBillMutationOptions } from '@/remotes/mutations/bill'; +import { BillCreateButton } from './bill-create-button'; +import { InformationFormItem } from './information-form-item'; +import { StepFormItem } from './step-form-item'; + +const createSettleSchema = z.object({ + title: z.string().min(1, '필수 입력 값입니다.'), + description: z.string().optional(), + heldAt: z.date(), + billAccountId: z.number(), + invitedAuthorityIds: z.array(z.number()), + gatherings: z + .array( + z.object({ + title: z.string().min(1, '필수 입력 값입니다.'), + receipt: z.object({ + amount: z + .string() + .min(1, '필수 입력 값입니다.') + .regex(/^0$|^[1-9]\d{0,2}(,\d{3})*$/, '올바른 금액 형식이어야 합니다.'), + }), + }), + ) + .min(1, '최소 하나의 차수 정보가 필요합니다.'), +}); + +const parseFormattedAmount = (amount: string) => { + return Number(amount.replace(/,/g, '')); +}; + +type CreateSettleSchema = z.infer; + +const FORM_ID = 'create-settle-form'; + +export const BillForm = () => { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(createSettleSchema), + defaultValues: { + title: '', + heldAt: new Date(), + description: '', + billAccountId: 1, + invitedAuthorityIds: [1, 2], + gatherings: [{ title: '', receipt: { amount: '' } }], + }, + }); + + const { mutate: createBillMutate, isPending } = useMutation( + createBillMutationOptions({ + onSuccess: (response) => { + router.replace(`/bills/create/${response.data.billId}`); + }, + onError: () => { + console.log('에러'); + }, + }), + ); + + const handleSubmitSettle = (formData: CreateSettleSchema) => { + const { title, description, heldAt, gatherings, billAccountId, invitedAuthorityIds } = formData; + const params = { + title, + description, + billAccountId, + invitedAuthorityIds, + gatherings: gatherings.map((gathering, index) => ({ + ...gathering, + heldAt: heldAt.toISOString(), + roundNumber: index + 1, + receipt: { amount: parseFormattedAmount(gathering.receipt.amount) }, + })), + }; + + createBillMutate(params); + }; + + const isDisabled = !form.formState.isValid || form.formState.isSubmitting || isPending; + + return ( +
    + + + + + + + + ); +}; diff --git a/apps/admin/app/(auth)/bills/create/_components/form/form-date.tsx b/apps/admin/app/(auth)/bills/create/_components/form/form-date.tsx new file mode 100644 index 00000000..ad67409b --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/_components/form/form-date.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Calendar, FormControl, FormField, FormItem, FormLabel } from '@dpm-core/shared'; +import { ko } from 'date-fns/locale'; +import { useFormContext } from 'react-hook-form'; + +export const FormDate = () => { + const form = useFormContext(); + + return ( +
    +
    + ( + + + 회식 날짜 + + + date.toLocaleDateString('ko-KR', { month: 'long' }), + formatWeekdayName: (date) => + date.toLocaleDateString('en-US', { weekday: 'short' }).slice(0, 2), + }} + className="px-5 py-4.5 border border-line-subtle rounded-md" + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date('1900-01-01')} + classNames={{ + root: 'w-full h-full', + months: 'w-full h-full relative', + month: 'flex flex-col w-full gap-3', + month_caption: 'flex items-center justify-center w-full h-7', + caption_label: 'text-body1 text-label-normal font-semibold', + nav: 'absolute top-0 inset-x-0 flex items-center justify-between', + weekdays: + 'grid grid-cols-7 gap-x-[calc((100%-32px*7)/6)] mb-2 place-items-center', + weekday: + 'w-8 h-8 flex items-center justify-center text-body2 font-medium text-label-assistive', + week: 'grid grid-cols-7 gap-x-[calc((100%-32px*7)/6)] mb-2 place-items-center', + day: 'w-8 h-8 flex items-center justify-center text-body2 font-medium text-label-subtle ', + today: 'bg-background-strong text-label-assistive rounded-md', + selected: '!bg-background-inverse rounded-md !text-white', + button_previous: + 'w-7 h-7 border border-line-subtle rounded-md flex items-center justify-center text-label-normal cursor-pointer', + button_next: + 'w-7 h-7 border border-line-subtle rounded-md flex items-center justify-center text-label-normal cursor-pointer', + outside: '!text-label-disable', + }} + /> + + + )} + /> +
    +
    + ); +}; diff --git a/apps/admin/app/(auth)/bills/create/_components/form/form-description.tsx b/apps/admin/app/(auth)/bills/create/_components/form/form-description.tsx new file mode 100644 index 00000000..0c35ce71 --- /dev/null +++ b/apps/admin/app/(auth)/bills/create/_components/form/form-description.tsx @@ -0,0 +1,36 @@ +import { FormControl, FormField, FormItem, FormLabel } from '@dpm-core/shared'; +import { useFormContext, useWatch } from 'react-hook-form'; + +const MAX_LENGTH = 50; +export const FormDescription = () => { + const form = useFormContext(); + const description = useWatch({ control: form.control, name: 'description' }); + + return ( +
    + ( + + + 정산서 설명 + (선택) + + +