329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
'use client';
|
|
import { useState } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
|
import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
|
|
import type { AccentTone, StatusKind } from '@/components';
|
|
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
|
import { bookingReviewPath, ROUTES } from '@/constants';
|
|
import { formatShamsiDate, localeTag } from '@/utils';
|
|
import { useBookingList } from '@/services/bookings';
|
|
import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
|
|
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
|
|
import { useCustomerRequests } from '@/services/bookingRequests';
|
|
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
|
|
import { useReviewEligibility } from '@/services/reviews';
|
|
|
|
type BookingsTab = 'pending' | 'active' | 'past';
|
|
|
|
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
|
|
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
|
|
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
|
|
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
|
|
'pending_nurse_response',
|
|
'accepted_awaiting_payment',
|
|
];
|
|
|
|
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
|
|
neutral: 'neutral',
|
|
info: 'info',
|
|
pending: 'primary',
|
|
verified: 'success',
|
|
active: 'success',
|
|
rejected: 'error',
|
|
};
|
|
|
|
/**
|
|
* Customer رزروها — the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
|
|
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
|
|
* `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
|
|
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
|
|
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
|
|
* `pageSize` (the C2 results pattern) — booking #21+ stays reachable.
|
|
*/
|
|
export default function BookingsScreen() {
|
|
const t = useTranslations('booking');
|
|
const router = useRouter();
|
|
const locale = useLocale();
|
|
|
|
const [tab, setTab] = useState<BookingsTab>('active');
|
|
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
|
|
|
|
const pendingQuery = useCustomerRequests();
|
|
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
|
|
PENDING_REQUEST_STATUSES.includes(item.status),
|
|
);
|
|
|
|
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
|
|
const allBookings = bookingsQuery.data?.items ?? [];
|
|
const total = bookingsQuery.data?.total ?? 0;
|
|
const hasMore = allBookings.length < total;
|
|
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
|
|
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
|
|
|
|
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
|
|
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
|
|
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
|
|
|
|
return (
|
|
<Stack sx={{ gap: 3 }}>
|
|
<Stack>
|
|
<Typography variant="h5" component="h1">
|
|
{t('list_title')}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{t('list_subtitle')}
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
|
|
<Tab
|
|
value="pending"
|
|
data-tab="pending"
|
|
label={
|
|
pendingItems.length > 0 ? (
|
|
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
|
|
{t('tab_pending')}
|
|
</Badge>
|
|
) : (
|
|
t('tab_pending')
|
|
)
|
|
}
|
|
/>
|
|
<Tab value="active" data-tab="active" label={t('tab_active')} />
|
|
<Tab value="past" data-tab="past" label={t('tab_past')} />
|
|
</Tabs>
|
|
|
|
{tab === 'pending' ? (
|
|
pendingQuery.isLoading ? (
|
|
<ListSkeleton />
|
|
) : pendingQuery.isError ? (
|
|
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
|
|
) : pendingItems.length === 0 ? (
|
|
<EmptyState
|
|
icon="pending"
|
|
title={t('pending_empty_title')}
|
|
body={t('pending_empty_body')}
|
|
action={
|
|
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
|
|
{t('missing_nurse_cta')}
|
|
</AppButton>
|
|
}
|
|
/>
|
|
) : (
|
|
<Stack sx={{ gap: 2 }}>
|
|
{pendingItems.map((item) => (
|
|
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
|
|
))}
|
|
</Stack>
|
|
)
|
|
) : null}
|
|
|
|
{tab === 'active' ? (
|
|
bookingsQuery.isLoading ? (
|
|
<ListSkeleton />
|
|
) : bookingsQuery.isError ? (
|
|
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
|
|
) : activeItems.length === 0 ? (
|
|
<EmptyState
|
|
icon="bookings"
|
|
title={t('active_empty_title')}
|
|
body={t('active_empty_body')}
|
|
action={
|
|
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
|
|
{t('missing_nurse_cta')}
|
|
</AppButton>
|
|
}
|
|
/>
|
|
) : (
|
|
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
|
|
)
|
|
) : null}
|
|
|
|
{tab === 'past' ? (
|
|
bookingsQuery.isLoading ? (
|
|
<ListSkeleton />
|
|
) : bookingsQuery.isError ? (
|
|
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
|
|
) : pastItems.length === 0 ? (
|
|
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
|
|
) : (
|
|
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
|
|
)
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function BookingRows({
|
|
items,
|
|
locale,
|
|
onOpen,
|
|
hasMore,
|
|
onLoadMore,
|
|
loadingMore,
|
|
loadMoreLabel,
|
|
}: {
|
|
items: BookingListItemDto[];
|
|
locale: string;
|
|
onOpen: (id: number) => void;
|
|
hasMore: boolean;
|
|
onLoadMore: () => void;
|
|
loadingMore: boolean;
|
|
loadMoreLabel: string;
|
|
}) {
|
|
return (
|
|
<Stack sx={{ gap: 2 }}>
|
|
{items.map((item) => (
|
|
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
|
|
))}
|
|
{hasMore ? (
|
|
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
|
|
{loadMoreLabel}
|
|
</AppButton>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
|
|
const t = useTranslations('booking');
|
|
const kind = BOOKING_STATUS_KIND[item.status];
|
|
const isCompleted = item.status === 'completed' || item.status === 'closed';
|
|
|
|
return (
|
|
<AccentCard
|
|
tone={KIND_TO_ACCENT[kind]}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onOpen}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onOpen();
|
|
}
|
|
}}
|
|
data-booking-row={item.id}
|
|
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
|
|
>
|
|
<Stack sx={{ gap: 1.5 }}>
|
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
|
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
{item.counterpartyName}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
|
</Typography>
|
|
</Stack>
|
|
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
|
|
</Stack>
|
|
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
|
</Typography>
|
|
|
|
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
|
|
</Stack>
|
|
</AccentCard>
|
|
);
|
|
}
|
|
|
|
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
|
|
* The eligibility read is gated to completed/closed rows only (`enabled`) — an active booking never fires
|
|
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
|
|
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
|
|
const t = useTranslations('reviews');
|
|
const router = useRouter();
|
|
const locale = useLocale();
|
|
const eligibility = useReviewEligibility(bookingId, { enabled });
|
|
|
|
if (!eligibility.data?.canReview) return null;
|
|
|
|
return (
|
|
<AppButton
|
|
variant="text"
|
|
color="primary"
|
|
size="small"
|
|
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
|
|
}}
|
|
sx={{ alignSelf: 'flex-start', px: 0 }}
|
|
>
|
|
{t('cta_leave')}
|
|
</AppButton>
|
|
);
|
|
}
|
|
|
|
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
|
|
function PendingRequestRow({
|
|
item,
|
|
locale,
|
|
onOpen,
|
|
}: {
|
|
item: BookingRequestListItem;
|
|
locale: string;
|
|
onOpen: () => void;
|
|
}) {
|
|
const t = useTranslations('booking');
|
|
const accepted = item.status === 'accepted_awaiting_payment';
|
|
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
|
|
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
|
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
|
const dateLabel = formatShamsiDate(startDate, locale);
|
|
const timeLabel = timeFmt.format(startDate);
|
|
|
|
return (
|
|
<AccentCard
|
|
tone={accepted ? 'secondary' : 'primary'}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onOpen}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onOpen();
|
|
}
|
|
}}
|
|
data-request-row={item.id}
|
|
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
|
|
>
|
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
|
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
{item.counterpartyName}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{dateLabel} ·{' '}
|
|
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
{timeLabel}
|
|
</Typography>
|
|
</Typography>
|
|
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
|
|
</Stack>
|
|
{deadline ? (
|
|
<CountdownTimer
|
|
deadlineIso={deadline}
|
|
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
|
|
urgent={accepted}
|
|
size="sm"
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
</AccentCard>
|
|
);
|
|
}
|
|
|
|
function ListSkeleton() {
|
|
return (
|
|
<Stack sx={{ gap: 2 }}>
|
|
{[0, 1].map((key) => (
|
|
<Skeleton key={key} variant="rounded" height={96} />
|
|
))}
|
|
</Stack>
|
|
);
|
|
}
|