Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
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>
);
}