Skip to content
5 changes: 3 additions & 2 deletions src/components/estimate/MoverEstimateTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import Link from "next/link";
import { usePathname } from "next/navigation";

import { Text } from "@/components/common/Text";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { cn } from "@/lib/utils/cn";

const TABS = [
{ href: "/estimate/sent", label: "보낸 견적 조회" },
{ href: "/estimate/rejected", label: "반려 요청" },
{ href: APP_ROUTES.MOVER_ESTIMATES.SENT, label: "보낸 견적 조회" },
{ href: APP_ROUTES.MOVER_ESTIMATES.REJECTED, label: "반려 요청" },
] as const;

export default function MoverEstimateTabs() {
Expand Down
10 changes: 2 additions & 8 deletions src/components/estimate/RejectedRequestsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ export default function RejectedRequestsPage() {
<main className="bg-background-subtle min-h-[calc(100vh-108px)] px-24 pt-24 pb-40 md:min-h-[calc(100vh-142px)] md:px-72 md:pt-32 lg:min-h-[calc(100vh-168px)] lg:px-0 lg:pt-[59px] lg:pb-[107px]">
<h1 className="sr-only">반려 요청</h1>

{query.isPending ? (
<Text variant="lg-regular" className="text-text-muted py-80 text-center">
반려 요청을 불러오는 중이에요.
</Text>
) : null}
{query.isPending ? <EstimatesQueryStatus message="반려 요청을 불러오는 중이에요." /> : null}

{query.isError ? (
<EstimatesQueryStatus
Expand All @@ -54,9 +50,7 @@ export default function RejectedRequestsPage() {
) : null}

{items.length === 0 && !query.isPending && !query.isError ? (
<Text variant="lg-regular" className="text-text-muted py-80 text-center">
반려한 요청이 없어요.
</Text>
<EstimatesQueryStatus message="반려한 요청이 없어요." />
) : null}

{items.length > 0 ? (
Expand Down
95 changes: 65 additions & 30 deletions src/components/estimate/sent/SentEstimateDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,67 @@
"use client";

import { Text } from "@/components/common/Text";
import EstimateDetailLayout from "@/components/estimate/detail/EstimateDetailLayout";
import EstimateDetailLayout, {
EstimateDetailQueryState,
} from "@/components/estimate/detail/EstimateDetailLayout";
import { EstimateDetailInfoSection } from "@/components/estimate/detail/EstimateDetailInfoSection";
import EstimateDetailPrice from "@/components/estimate/detail/EstimateDetailPrice";
import EstimateDetailShare from "@/components/estimate/detail/EstimateDetailShare";
import { DesignatedChip, MoveTypeChip } from "@/components/estimate/received/MoveTypeChip";
import { useSentEstimateDetail } from "@/hooks/useSentEstimates";
import FrameIcon from "@/icons/frame.svg";
import { MOVE_TYPE_LABEL } from "@/lib/constants/moveType";
import { formatKoreanDateTime } from "@/lib/utils/date";
import type { SentEstimate } from "@/types/sentEstimate";

interface SentEstimateDetailPageProps {
estimateId: number;
}

// 추후 백엔드 API 개발 후 수정하겠습니다.
const DETAIL = {
customerName: "김민서",
moveType: "OFFICE" as const,
isDesignated: true,
price: 180000,
requestedAt: "24.08.26",
moveDate: "2024. 08. 26(월) 오전 10:00",
fromAddress: "서울 중구 삼일대로 343",
toAddress: "서울 강남구 선릉로 428",
};
function formatAddress(address: string, detailAddress: string | null) {
return [address, detailAddress].filter(Boolean).join(" ");
}

function SentEstimateSummary({ estimate }: { estimate: SentEstimate }) {
const isConfirmed = estimate.status !== "SENT";

function SentEstimateSummary() {
return (
<section className="flex w-full flex-col gap-20 md:gap-26" aria-label="확정 견적 요약">
<section className="flex w-full flex-col gap-20 md:gap-26" aria-label="보낸 견적 요약">
<div className="flex w-full flex-col gap-12 md:gap-20">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8 md:gap-12">
<MoveTypeChip moveType={DETAIL.moveType} size="sm" className="md:hidden" />
<MoveTypeChip moveType={DETAIL.moveType} size="md" className="hidden md:inline-flex" />
{DETAIL.isDesignated ? (
<MoveTypeChip
moveType={estimate.estimateRequest.moveType}
size="sm"
className="md:hidden"
/>
<MoveTypeChip
moveType={estimate.estimateRequest.moveType}
size="md"
className="hidden md:inline-flex"
/>
{estimate.isDesignated ? (
<>
<DesignatedChip size="sm" className="md:hidden" />
<DesignatedChip size="md" className="hidden md:inline-flex" />
</>
) : null}
</div>

<span className="text-text-brand flex shrink-0 items-center gap-4">
<FrameIcon className="size-20 shrink-0" aria-hidden="true" />
<Text variant="lg-bold">확정견적</Text>
</span>
{isConfirmed ? (
<span className="text-text-brand flex shrink-0 items-center gap-4">
<FrameIcon className="size-20 shrink-0" aria-hidden="true" />
<Text variant="lg-bold">확정견적</Text>
</span>
) : null}
</div>

<Text
as="h2"
variant={{ base: "lg-semibold", md: "2xl-semibold" }}
className="text-text-secondary"
>
{DETAIL.customerName} 고객님
{estimate.customer.name} 고객님
</Text>
</div>

Expand All @@ -61,9 +71,28 @@ function SentEstimateSummary() {
}

export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetailPageProps) {
const query = useSentEstimateDetail(estimateId);

if (query.isPending) {
return <EstimateDetailQueryState title="견적 상세" message="견적을 불러오는 중이에요." />;
}

if (query.isError || !query.data) {
return (
<EstimateDetailQueryState
title="견적 상세"
message="견적을 불러오지 못했어요."
actionLabel="다시 시도"
onAction={() => void query.refetch()}
/>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const estimate = query.data;
const request = estimate.estimateRequest;

return (
<EstimateDetailLayout
key={estimateId}
showProfile={false}
contentClassName="pt-35 pb-64 md:pt-[46px] md:pb-80 lg:pt-[43px] lg:pb-37-5"
rowClassName="gap-20 md:gap-32 lg:gap-0"
Expand All @@ -72,17 +101,23 @@ export default function SentEstimateDetailPage({ estimateId }: SentEstimateDetai
main={
<>
<div className="flex w-full flex-col gap-20 md:gap-26">
<SentEstimateSummary />
<EstimateDetailPrice price={DETAIL.price} />
<SentEstimateSummary estimate={estimate} />
<EstimateDetailPrice price={estimate.price} />
</div>

<EstimateDetailInfoSection
rows={[
{ label: "견적 요청일", value: DETAIL.requestedAt },
{ label: "서비스", value: "사무실이사" },
{ label: "이용일", value: DETAIL.moveDate },
{ label: "출발지", value: DETAIL.fromAddress },
{ label: "도착지", value: DETAIL.toAddress },
{ label: "견적 요청일", value: formatKoreanDateTime(request.requestedAt) },
{ label: "서비스", value: MOVE_TYPE_LABEL[request.moveType] },
{ label: "이용일", value: formatKoreanDateTime(request.moveDate) },
{
label: "출발지",
value: formatAddress(request.fromAddress, request.fromDetailAddress),
},
{
label: "도착지",
value: formatAddress(request.toAddress, request.toDetailAddress),
},
]}
/>
</>
Expand Down
110 changes: 59 additions & 51 deletions src/components/estimate/sent/SentEstimatesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,85 +1,93 @@
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";

import { Text } from "@/components/common/Text";
import MoverEstimateTabs from "@/components/estimate/MoverEstimateTabs";
import SentEstimateCard, {
type SentEstimateItem,
} from "@/components/estimate/sent/SentEstimateCard";

const PAGE_SIZE = 6;

// API 개발 후에 제거하고 연결하겠습니다.
const SENT_ESTIMATES: SentEstimateItem[] = Array.from({ length: 18 }, (_, index) => {
const status: SentEstimateItem["status"] =
index === 0 ? "CONFIRMED" : index === 1 ? "SENT" : "COMPLETED";

return {
id: index + 1,
customerName: "김인서",
moveType: "SMALL",
isDesignated: true,
fromRegion: "서울시 중구",
toRegion: "경기도 수원시",
moveDate: "2024-07-01",
price: 180000,
status,
};
});
import EstimatesQueryStatus from "@/components/estimate/EstimatesQueryStatus";
import SentEstimateCard from "@/components/estimate/sent/SentEstimateCard";
import { useSentEstimates } from "@/hooks/useSentEstimates";
import { APP_ROUTES } from "@/lib/constants/appRoutes";

export default function SentEstimatesPage() {
const router = useRouter();
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);
const visibleEstimates = useMemo(() => SENT_ESTIMATES.slice(0, visibleCount), [visibleCount]);
const hasNextPage = visibleCount < SENT_ESTIMATES.length;
const query = useSentEstimates();
const { fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = query;
const estimates = query.data?.pages.flatMap((page) => page.data) ?? [];

useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNextPage) return;
if (!sentinel || !hasNextPage || isFetchingNextPage || isFetchNextPageError) return;

const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setVisibleCount((count) => Math.min(count + PAGE_SIZE, SENT_ESTIMATES.length));
if (entry?.isIntersecting) void fetchNextPage();
},
{ rootMargin: "240px 0px" },
);

observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage]);
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError]);

return (
<>
<MoverEstimateTabs />
<main className="bg-background-subtle min-h-[calc(100vh-108px)] px-24 pt-24 pb-[77px] md:min-h-[calc(100vh-142px)] md:px-72 md:pt-32 md:pb-40 lg:min-h-[calc(100vh-168px)] lg:px-0 lg:pt-[59px] lg:pb-[107px]">
<h1 className="sr-only">보낸 견적 조회</h1>

<div className="mx-auto grid w-full grid-cols-1 gap-20 md:max-w-[600px] md:gap-32 lg:max-w-[1200px] lg:grid-cols-2 lg:gap-24">
{visibleEstimates.map((estimate) => (
<div key={estimate.id}>
<SentEstimateCard
estimate={estimate}
onViewDetail={(estimateId) => router.push(`/estimate/sent/${estimateId}`)}
/>
</div>
))}
</div>
{query.isPending ? <EstimatesQueryStatus message="보낸 견적을 불러오는 중이에요." /> : null}

{query.isError ? (
<EstimatesQueryStatus
message="보낸 견적을 불러오지 못했어요."
actionLabel="다시 시도"
onAction={() => void query.refetch()}
/>
) : null}

{!query.isPending && !query.isError && estimates.length === 0 ? (
<EstimatesQueryStatus message="아직 보낸 견적이 없어요." />
) : null}

{estimates.length > 0 ? (
<div className="mx-auto grid w-full grid-cols-1 gap-20 md:max-w-[600px] md:gap-32 lg:max-w-[1200px] lg:grid-cols-2 lg:gap-24">
{estimates.map((estimate) => (
<div key={estimate.id}>
<SentEstimateCard
estimate={{
id: estimate.id,
customerName: estimate.customer.name,
moveType: estimate.estimateRequest.moveType,
isDesignated: estimate.isDesignated,
fromRegion: estimate.estimateRequest.fromRegion.name,
toRegion: estimate.estimateRequest.toRegion.name,
moveDate: estimate.estimateRequest.moveDate,
price: estimate.price,
status: estimate.status,
}}
onViewDetail={(estimateId) =>
router.push(APP_ROUTES.MOVER_ESTIMATES.SENT_DETAIL(estimateId))
}
/>
</div>
))}
</div>
) : null}

<div ref={sentinelRef} className="h-1 w-full" aria-hidden="true" />

{hasNextPage ? (
<Text
as="p"
variant="lg-regular"
className="text-text-muted py-32 text-center"
role="status"
>
다음 견적을 불러오는 중이에요.
</Text>
{query.isFetchingNextPage ? (
<EstimatesQueryStatus message="다음 견적을 불러오는 중이에요." />
) : null}

{isFetchNextPageError ? (
<EstimatesQueryStatus
message="다음 견적을 불러오지 못했어요."
actionLabel="다시 시도"
onAction={() => void fetchNextPage()}
/>
) : null}
</main>
</>
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useSentEstimates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useApiInfiniteQuery } from "@/hooks/queries/useApiInfiniteQuery";
import { useApiQuery } from "@/hooks/queries/useApiQuery";
import {
fetchSentEstimateDetail,
fetchSentEstimates,
type SentEstimateListQuery,
} from "@/lib/api/sentEstimates";
import { QUERY_KEYS } from "@/lib/constants/queryKeys";

const PAGE_SIZE = 6;

//보낸 견적 목록 및 무한 스크롤 처리
export function useSentEstimates(query: Omit<SentEstimateListQuery, "page" | "limit"> = {}) {
return useApiInfiniteQuery({
queryKey: QUERY_KEYS.ESTIMATES.SENT_LIST(query.status),
queryFn: ({ pageParam }) => fetchSentEstimates({ ...query, page: pageParam, limit: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.pagination.hasNext ? lastPage.pagination.page + 1 : undefined,
});
}

//특정 견적 상세 조회 및 캐시 관리
export function useSentEstimateDetail(estimateId: number) {
return useApiQuery({
queryKey: QUERY_KEYS.ESTIMATES.SENT_DETAIL(estimateId),
queryFn: () => fetchSentEstimateDetail(estimateId),
enabled: Number.isSafeInteger(estimateId) && estimateId > 0,
});
}
Loading