Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
155 lines
6.8 KiB
TypeScript
155 lines
6.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { formatAmd } from '@barev/i18n';
|
||
import { Badge, Button, Chip } from '@barev/ui';
|
||
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
|
||
import { useState } from 'react';
|
||
|
||
const STATUSES = ['NEW', 'LINKED', 'ORDERED', 'CANCELLED'] as const;
|
||
type Status = (typeof STATUSES)[number];
|
||
const STATUS_TONE: Record<Status, string> = {
|
||
NEW: 'var(--bc-perks)',
|
||
LINKED: 'var(--bc-pink)',
|
||
ORDERED: 'var(--bc-home)',
|
||
CANCELLED: 'var(--bc-line)',
|
||
};
|
||
|
||
/** Заявки с сайта (barevclub.com/clean): появляются сразу после отправки формы, список обновляется сам */
|
||
export function WebRequests() {
|
||
const { t, dt, locale } = useI18n();
|
||
const trpc = useTRPC();
|
||
const qc = useQueryClient();
|
||
const toast = useToast();
|
||
const [status, setStatus] = useState<Status | undefined>();
|
||
const list = useQuery({ ...trpc.admin.webRequests.list.queryOptions({ status }), refetchInterval: 15_000 });
|
||
const update = useMutation(
|
||
trpc.admin.webRequests.update.mutationOptions({
|
||
onSuccess: () => {
|
||
toast(t('admin.saved'));
|
||
void qc.invalidateQueries();
|
||
},
|
||
onError: (e) => toast(errorText(t, e), 'error'),
|
||
}),
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<div className="bc-between">
|
||
<h1 className="bc-h2">{t('admin.nav.webRequests')}</h1>
|
||
<div className="bc-chips">
|
||
<Chip pressed={!status} onClick={() => setStatus(undefined)}>
|
||
{t('admin.webRequests.all')}
|
||
</Chip>
|
||
{STATUSES.map((s) => (
|
||
<Chip key={s} pressed={status === s} onClick={() => setStatus(s)}>
|
||
{t(`admin.webRequests.status_${s}`)}
|
||
</Chip>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{list.data?.length === 0 ? (
|
||
<p className="bc-muted">{t('admin.webRequests.empty')}</p>
|
||
) : (
|
||
<table className="grid">
|
||
<thead>
|
||
<tr>
|
||
<th>{t('admin.webRequests.number')}</th>
|
||
<th>{t('admin.webRequests.when')}</th>
|
||
<th>{t('admin.webRequests.what')}</th>
|
||
<th>{t('admin.webRequests.address')}</th>
|
||
<th>{t('admin.webRequests.contact')}</th>
|
||
<th>{t('admin.webRequests.price')}</th>
|
||
<th>{t('admin.webRequests.status')}</th>
|
||
<th>{t('admin.webRequests.note')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{list.data?.map((r) => {
|
||
const p = r.params as { cleaningType?: string; rooms?: number; bathrooms?: number; extras?: Record<string, number> };
|
||
const extras = Object.entries(p.extras ?? {});
|
||
return (
|
||
<tr key={r.id}>
|
||
<td>
|
||
<b>{r.number}</b>
|
||
<div className="bc-small bc-muted">{t('admin.webRequests.created', { date: dt(r.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}</div>
|
||
</td>
|
||
<td className="bc-small">{dt(r.preferredAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
|
||
<td>
|
||
<b>{t(`params.cleaning.cleaningType.${p.cleaningType ?? 'regular'}`)}</b>
|
||
<div className="bc-small">
|
||
{t('params.cleaning.rooms.count', { count: p.rooms ?? 1 })}, {t('params.cleaning.bathrooms.count', { count: p.bathrooms ?? 1 })}
|
||
</div>
|
||
{extras.length > 0 && (
|
||
<div className="bc-small bc-muted">{extras.map(([code, qty]) => `${t(`extras.${code}.name`)}${qty > 1 ? ` × ${qty}` : ''}`).join(', ')}</div>
|
||
)}
|
||
</td>
|
||
<td className="bc-small">
|
||
{t(`districts.${r.district.code}`)}
|
||
<div>{r.addressText}</div>
|
||
{r.note && <div className="bc-muted">{r.note}</div>}
|
||
</td>
|
||
<td>
|
||
{r.name && <b>{r.name}</b>}
|
||
<div>
|
||
<a href={`tel:${r.phone}`}>{r.phone}</a>
|
||
</div>
|
||
{r.client && (
|
||
<div className="bc-small bc-muted">
|
||
{r.client.user.username ? `@${r.client.user.username}` : `${r.client.user.firstName ?? ''} ${r.client.user.lastName ?? ''}`}
|
||
</div>
|
||
)}
|
||
<div className="bc-small bc-muted">{r.locale.toUpperCase()}</div>
|
||
</td>
|
||
<td className="mono">{formatAmd(locale, r.totalPrice)}</td>
|
||
<td>
|
||
<Badge style={{ background: STATUS_TONE[r.status] }}>{t(`admin.webRequests.status_${r.status}`)}</Badge>
|
||
{r.order && (
|
||
<div className="bc-small" style={{ marginTop: 6 }}>
|
||
{t('admin.webRequests.order', { number: r.order.number })}
|
||
</div>
|
||
)}
|
||
<div style={{ marginTop: 8 }}>
|
||
{r.status === 'CANCELLED' ? (
|
||
<Button size="sm" variant="light" onClick={() => update.mutate({ id: r.id, status: 'REOPEN' })}>
|
||
{t('admin.webRequests.reopen')}
|
||
</Button>
|
||
) : (
|
||
r.status !== 'ORDERED' && (
|
||
<Button
|
||
size="sm"
|
||
variant="light"
|
||
onClick={async () => (await confirmDialog(t('admin.webRequests.cancelConfirm', { number: r.number }))) && update.mutate({ id: r.id, status: 'CANCELLED' })}
|
||
>
|
||
{t('admin.webRequests.cancel')}
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td style={{ minWidth: 200 }}>
|
||
<NoteEditor initial={r.adminNote ?? ''} onSave={(adminNote) => update.mutate({ id: r.id, adminNote })} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function NoteEditor({ initial, onSave }: { initial: string; onSave: (v: string) => void }) {
|
||
const { t } = useI18n();
|
||
const [value, setValue] = useState(initial);
|
||
return (
|
||
<div className="bc-stack" style={{ gap: 6 }}>
|
||
<textarea className="bc-input" style={{ minHeight: 60 }} value={value} maxLength={1000} placeholder={t('admin.webRequests.notePlaceholder')} onChange={(e) => setValue(e.target.value)} />
|
||
{value !== initial && (
|
||
<Button size="sm" variant="outline" onClick={() => onSave(value)}>
|
||
{t('admin.webRequests.saveNote')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|