Backoffice stage 4: partners (agencies, venues) and sales CRM
- CRM: configurable pipeline (default: New → Contact → Meeting → Negotiation → Connected / Lost), drag-and-drop board, lead card with call/meeting/note history, photos and files, tasks with Telegram reminders at the due time, map of venues (OpenStreetMap via Leaflet), lost reason. Sales reps see only their own leads. - "Connected" in one click: a venue becomes a club card partner with a one-time owner link to the work bot; an agency gets a code and a client link. The sales rep is recorded on the partner for the bonus. - Sales bonus formula in settings (per venue, per agency, % of revenue from the rep's agency clients for N months); defaults are 0 until the owner sets them. - Agencies (superadmin): clients, gift subscriptions, reward by month, payouts, client link, edit. Venues: offers by membership level (5 languages), staff, card checks, 8-week activity, owner link, map point. - Schema: SalesStage/SalesLead/SalesActivity/SalesTask; salesId on Agency and Partner; lat/lng/phone/note on Partner; agencyId on Payout. - Tests for stage rules, conversion, task reminders and bonus; dev:crm-demo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a848bb13e2
commit
ac786cc5cc
30 files changed
+4311
-11
No files matched your search
@@ -0,0 +1,354 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
|
||||
|
||||
function CopyLink({ url }: { url: string }) {
|
||||
const { t } = useT();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div className="bo-linkbox">
|
||||
<code>{url}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
toast(t('ui.copied'));
|
||||
}}
|
||||
>
|
||||
{t('ui.copy')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { CopyLink };
|
||||
|
||||
export function Agencies() {
|
||||
const { t, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const list = useQuery(trpc.bo.partners.agencies.queryOptions());
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('ag.title')}</h1>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('ag.add')}
|
||||
</button>
|
||||
</div>
|
||||
{!list.data ? (
|
||||
<Skeleton h={240} />
|
||||
) : list.data.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{t('ag.empty')}</div>
|
||||
) : (
|
||||
<div className="bo-panel" style={{ padding: 8 }}>
|
||||
<table className="bo-table bo-ptable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('ag.col.name')}</th>
|
||||
<th className="bo-r">{t('ag.col.clients')}</th>
|
||||
<th className="bo-r">{t('ag.col.gifts')}</th>
|
||||
<th className="bo-r">{t('ag.col.month')}</th>
|
||||
<th className="bo-r">{t('ag.col.balance')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.map((a) => (
|
||||
<tr key={a.id} onClick={() => navigate(`/agencies/${a.id}`)} data-off={!a.active || undefined}>
|
||||
<td>
|
||||
<a href={`#/agencies/${a.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar name={a.name} id={a.id} size={38} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{a.name}</span>
|
||||
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')}</span>
|
||||
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="bo-r bo-num">{a.clients}</td>
|
||||
<td className="bo-r bo-num">{a.gifts}</td>
|
||||
<td className="bo-r bo-num">{amd(a.thisMonth)}</td>
|
||||
<td className="bo-r">
|
||||
<b className="bo-num">{amd(a.balance)}</b>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
|
||||
{t('ag.hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{creating && <AgencyDialog onClose={() => setCreating(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgencyCard({ id }: { id: string }) {
|
||||
const { t, amd, date, locale } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const a = useQuery(trpc.bo.partners.agency.queryOptions({ id }));
|
||||
const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null);
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.agency.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.agencies.queryKey() });
|
||||
};
|
||||
const back = (
|
||||
<a className="bo-back" href="#/agencies">
|
||||
<I name="back" size={16} />
|
||||
{t('ag.back')}
|
||||
</a>
|
||||
);
|
||||
if (!a.data)
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{a.isError ? <div className="bo-panel bo-empty">{errText(t, a.error)}</div> : <Skeleton h={300} />}
|
||||
</>
|
||||
);
|
||||
const d = a.data;
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span className="bo-num">{d.code}</span>
|
||||
<span>{t('ag.pct', { n: d.commissionPct })}</span>
|
||||
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
|
||||
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
|
||||
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('ag.link')}</h2>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 10 }}>
|
||||
{t('ag.linkHint', { code: d.code })}
|
||||
</p>
|
||||
<CopyLink url={d.link} />
|
||||
</section>
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('ag.clients')}</h2>
|
||||
<span className="bo-faint bo-small">
|
||||
{d.clients.length} · {t('ag.giftsN', { n: d.gifts })}
|
||||
</span>
|
||||
</div>
|
||||
{d.clients.length === 0 ? (
|
||||
<p className="bo-hint">{t('ag.noClients')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.clients.map((c) => (
|
||||
<li key={c.id}>
|
||||
<a href={`#/clients/${c.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<b>{c.name}</b>
|
||||
<span className="bo-small bo-faint" style={{ display: 'block' }}>
|
||||
{c.attachedAt ? date(c.attachedAt) : '—'} · {t('ag.orders', { n: c.orders })}
|
||||
</span>
|
||||
</a>
|
||||
<PlanBadge plan={c.plan} t={t} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel bo-money-card">
|
||||
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
|
||||
{t('ag.toPay')}
|
||||
</span>
|
||||
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
|
||||
{amd(d.balance)}
|
||||
</div>
|
||||
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<span>
|
||||
{t('ag.accrued')} <b className="bo-num">{amd(d.accrued)}</b>
|
||||
</span>
|
||||
<span>
|
||||
{t('prv.paidTotal')} <b className="bo-num">{amd(d.paid)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--sm" disabled={d.balance <= 0} onClick={() => setDlg('payout')}>
|
||||
{t('prv.payout')}
|
||||
</button>
|
||||
{d.months.length > 0 && (
|
||||
<ul className="bo-list" style={{ marginTop: 12 }}>
|
||||
{d.months.map((m) => (
|
||||
<li key={m.period}>
|
||||
<span className="bo-grow" style={{ textTransform: 'capitalize' }}>
|
||||
{formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="bo-small" style={{ opacity: 0.75 }}>
|
||||
{t('ag.orders', { n: m.orders })}
|
||||
</span>
|
||||
<b className="bo-num">{amd(m.amount)}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{d.payouts.length > 0 && (
|
||||
<p className="bo-small" style={{ marginTop: 10, opacity: 0.8 }}>
|
||||
{t('ag.payouts')}: {d.payouts.slice(0, 5).map((x) => `${amd(x.amount)}${x.paidAt ? ` (${date(x.paidAt)})` : ''}`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('crm.contacts')}</h2>
|
||||
</div>
|
||||
<p style={{ margin: 0 }}>{d.contact ?? '—'}</p>
|
||||
{d.note && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.note}</p>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{dlg === 'edit' && <AgencyDialog agency={d} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg === 'payout' && <AgencyPayout id={d.id} balance={d.balance} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const team = useQuery(trpc.bo.partners.salesTeam.queryOptions());
|
||||
const [f, setF] = useState({
|
||||
code: agency?.code ?? '',
|
||||
name: agency?.name ?? '',
|
||||
commissionPct: String(agency?.commissionPct ?? 10),
|
||||
contact: agency?.contact ?? '',
|
||||
note: agency?.note ?? '',
|
||||
active: agency?.active ?? true,
|
||||
salesId: agency?.sales?.id ?? '',
|
||||
});
|
||||
const save = useMutation(
|
||||
trpc.bo.partners.saveAgency.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.agencies.queryKey() });
|
||||
onDone?.();
|
||||
onClose();
|
||||
if (!agency) navigate(`/agencies/${r.id}`);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={agency ? t('ag.editTitle') : t('ag.add')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!f.name.trim() || !f.code.trim() || save.isPending}
|
||||
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.name')}>
|
||||
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('crm.agencyCode')} hint={t('crm.agencyCodeHint')}>
|
||||
<input className="bo-input" value={f.code} maxLength={32} onChange={(e) => setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('ag.pctLabel')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '') })} />
|
||||
</Field>
|
||||
<Field label={t('ag.sales')}>
|
||||
<select className="bo-select" value={f.salesId} onChange={(e) => setF({ ...f, salesId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{team.data?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('crm.contacts')}>
|
||||
<input className="bo-input" value={f.contact} maxLength={200} onChange={(e) => setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} />
|
||||
</Field>
|
||||
<Field label={t('crm.note')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 64 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('ag.active')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AgencyPayout({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
|
||||
const { t, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [amount, setAmount] = useState(String(balance));
|
||||
const [note, setNote] = useState('');
|
||||
const save = useMutation(
|
||||
trpc.bo.partners.agencyPayout.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onDone();
|
||||
toast(t('prv.payoutDone'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('ag.payoutTitle')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
|
||||
{t('prv.payoutConfirm')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('prv.amount')} hint={t('ag.balanceHint', { sum: amd(balance) })}>
|
||||
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
|
||||
</Field>
|
||||
<Field label={t('prv.payoutNote')}>
|
||||
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
||||
import { MapView, STAGE_COLORS, STAGE_COLOR_KEYS, type MapPoint } from '../components/map.tsx';
|
||||
|
||||
type Tab = 'board' | 'tasks' | 'map';
|
||||
type Kind = 'VENUE' | 'AGENCY';
|
||||
export const VENUE_CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
|
||||
const DAY = 86_400_000;
|
||||
const monthKey = (d = new Date()) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 7);
|
||||
|
||||
export function Crm({ me }: { me: Me }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const { query } = useLocation();
|
||||
const tab = (query.get('tab') as Tab) || 'board';
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const [kind, setKind] = useState<Kind | undefined>();
|
||||
const [ownerId, setOwnerId] = useState<string | undefined>();
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [dlg, setDlg] = useState<'new' | 'stages' | 'bonus' | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setQ(search.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
const team = useQuery(trpc.bo.crm.team.queryOptions());
|
||||
const summary = useQuery({ ...trpc.bo.crm.summary.queryOptions({ ownerId }), refetchInterval: 60_000 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<div>
|
||||
<h1 className="bo-title">{t('crm.title')}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<BonusChip me={me} salesId={ownerId} onEdit={isSuper ? () => setDlg('bonus') : undefined} configured={summary.data?.bonusConfigured} />
|
||||
{isSuper && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setDlg('stages')}>
|
||||
{t('crm.stagesBtn')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setDlg('new')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('crm.newLead')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bo-ogroups" role="tablist">
|
||||
{(['board', 'tasks', 'map'] as const).map((x) => (
|
||||
<button key={x} type="button" role="tab" className="bo-ogroup" aria-selected={tab === x} onClick={() => navigate(x === 'board' ? '/crm' : `/crm?tab=${x}`, { replace: true })}>
|
||||
{t(`crm.tab.${x}`)}
|
||||
{x === 'tasks' && !!summary.data?.overdue && <span className="bo-chat__dot">{summary.data.overdue}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'board' && (
|
||||
<div className="bo-toolbar">
|
||||
<label className="bo-search">
|
||||
<I name="search" size={18} />
|
||||
<input className="bo-input" type="search" value={search} placeholder={t('crm.search')} aria-label={t('crm.search')} onChange={(e) => setSearch(e.target.value)} />
|
||||
</label>
|
||||
<div className="bo-pills" role="group">
|
||||
{([undefined, 'VENUE', 'AGENCY'] as const).map((k) => (
|
||||
<button key={k ?? 'all'} type="button" className="bo-pill" aria-pressed={kind === k} onClick={() => setKind(k)}>
|
||||
{k ? t(`crm.kind.${k}`) : t('crm.kind.all')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isSuper && (team.data?.length ?? 0) > 1 && (
|
||||
<select className="bo-select" style={{ width: 'auto', minHeight: 36 }} value={ownerId ?? ''} onChange={(e) => setOwnerId(e.target.value || undefined)} aria-label={t('crm.owner')}>
|
||||
<option value="">{t('crm.allSales')}</option>
|
||||
{team.data!.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="none">{t('crm.noOwner')}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'board' && <Board kind={kind} ownerId={ownerId} search={q} showOwner={isSuper} counts={summary.data?.byStage} />}
|
||||
{tab === 'tasks' && <Tasks me={me} />}
|
||||
{tab === 'map' && <CrmMap />}
|
||||
|
||||
{dlg === 'new' && <LeadDialog me={me} onClose={() => setDlg(null)} />}
|
||||
{dlg === 'stages' && <StagesDialog onClose={() => setDlg(null)} />}
|
||||
{dlg === 'bonus' && <BonusDialog onClose={() => setDlg(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── воронка ─────
|
||||
|
||||
function Board({ kind, ownerId, search, showOwner, counts }: { kind?: Kind; ownerId?: string; search: string; showOwner: boolean; counts?: Record<string, number> }) {
|
||||
const { t, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
|
||||
const board = useQuery({ ...trpc.bo.crm.board.queryOptions({ kind, ownerId, search: search || undefined }), placeholderData: keepPreviousData });
|
||||
const [drag, setDrag] = useState<string | null>(null);
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<{ leadId: string; stage: { id: string; kind: string; name: string } } | null>(null);
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
|
||||
};
|
||||
const move = useMutation(trpc.bo.crm.move.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
|
||||
if (!stages.data || !board.data)
|
||||
return (
|
||||
<div className="bo-crm">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} h={300} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const drop = (stageId: string) => {
|
||||
const lead = board.data.find((l) => l.id === drag);
|
||||
const stage = stages.data.find((s) => s.id === stageId);
|
||||
setDrag(null);
|
||||
setOver(null);
|
||||
if (!lead || !stage || lead.stageId === stageId) return;
|
||||
// «Подключён» и «Отказ» требуют подробностей — открываем диалог
|
||||
if (stage.kind === 'WON' && !lead.converted) return setPending({ leadId: lead.id, stage });
|
||||
if (stage.kind === 'LOST') return setPending({ leadId: lead.id, stage });
|
||||
move.mutate({ id: lead.id, stageId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bo-crm">
|
||||
{stages.data.map((s) => {
|
||||
const leads = board.data.filter((l) => l.stageId === s.id);
|
||||
return (
|
||||
<section
|
||||
key={s.id}
|
||||
className="bo-crm__col"
|
||||
data-over={over === s.id || undefined}
|
||||
style={{ ['--stage' as string]: STAGE_COLORS[s.color] ?? STAGE_COLORS.blue }}
|
||||
onDragOver={(e) => {
|
||||
if (!drag) return;
|
||||
e.preventDefault();
|
||||
setOver(s.id);
|
||||
}}
|
||||
onDragLeave={() => setOver((o) => (o === s.id ? null : o))}
|
||||
onDrop={() => drop(s.id)}
|
||||
>
|
||||
<header className="bo-crm__head">
|
||||
<b>{s.name}</b>
|
||||
<span>{counts?.[s.id] ?? leads.length}</span>
|
||||
</header>
|
||||
<div className="bo-crm__cards">
|
||||
{leads.map((l) => {
|
||||
const days = Math.floor((Date.now() - l.stageChangedAt.getTime()) / DAY);
|
||||
return (
|
||||
<a
|
||||
key={l.id}
|
||||
href={`#/crm/${l.id}`}
|
||||
className="bo-lead"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
setDrag(l.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragEnd={() => setDrag(null)}
|
||||
data-dragging={drag === l.id || undefined}
|
||||
>
|
||||
<span className="bo-lead__top">
|
||||
<span className={`bo-chip bo-chip--${l.kind === 'AGENCY' ? 'SUPPORT' : 'provider'}`}>{t(`crm.kind1.${l.kind}`)}</span>
|
||||
{l.converted && <span className="bo-chip bo-chip--ok">{t('crm.connected')}</span>}
|
||||
<span className="bo-grow" />
|
||||
<span className="bo-faint bo-small" title={t('crm.inStage')}>
|
||||
{t('crm.days', { n: days })}
|
||||
</span>
|
||||
</span>
|
||||
<b className="bo-lead__name">{l.name}</b>
|
||||
{(l.contactName || l.phone) && <span className="bo-small bo-muted">{[l.contactName, l.phone].filter(Boolean).join(' · ')}</span>}
|
||||
{l.nextTask && (
|
||||
<span className={`bo-lead__task${l.nextTask.overdue ? ' is-overdue' : ''}`}>
|
||||
<I name="check" size={13} />
|
||||
{l.nextTask.title} · {dateTime(l.nextTask.dueAt)}
|
||||
</span>
|
||||
)}
|
||||
{showOwner && <span className="bo-small bo-faint">{l.owner?.name ?? t('crm.noOwner')}</span>}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{leads.length === 0 && <p className="bo-crm__empty">{t('crm.dropHere')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('crm.dragHint')}
|
||||
</p>
|
||||
{pending && pending.stage.kind === 'WON' && <ConvertDialog leadId={pending.leadId} canOpen={showOwner} onClose={() => setPending(null)} onDone={refresh} />}
|
||||
{pending && pending.stage.kind === 'LOST' && <LostDialog leadId={pending.leadId} stageId={pending.stage.id} onClose={() => setPending(null)} onDone={refresh} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── задачи ─────
|
||||
|
||||
function Tasks({ me }: { me: Me }) {
|
||||
const { t, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [done, setDone] = useState(false);
|
||||
const tasks = useQuery(trpc.bo.crm.tasks.queryOptions({ done }));
|
||||
const [adding, setAdding] = useState(false);
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.tasks.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
|
||||
};
|
||||
const toggle = useMutation(trpc.bo.crm.setTaskDone.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const now = Date.now();
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<div className="bo-pills">
|
||||
<button type="button" className="bo-pill" aria-pressed={!done} onClick={() => setDone(false)}>
|
||||
{t('crm.tasksOpen')}
|
||||
</button>
|
||||
<button type="button" className="bo-pill" aria-pressed={done} onClick={() => setDone(true)}>
|
||||
{t('crm.tasksDone')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setAdding(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('crm.addTask')}
|
||||
</button>
|
||||
</div>
|
||||
{!tasks.data ? (
|
||||
<Skeleton h={160} />
|
||||
) : tasks.data.length === 0 ? (
|
||||
<p className="bo-empty">{done ? t('crm.noDoneTasks') : t('crm.noTasks')}</p>
|
||||
) : (
|
||||
<ul className="bo-tasks">
|
||||
{tasks.data.map((x) => {
|
||||
const overdue = !x.doneAt && x.dueAt.getTime() < now;
|
||||
return (
|
||||
<li key={x.id} data-overdue={overdue || undefined} data-done={!!x.doneAt || undefined}>
|
||||
<label className="bo-tasks__check">
|
||||
<input type="checkbox" checked={!!x.doneAt} onChange={(e) => toggle.mutate({ id: x.id, done: e.target.checked })} aria-label={x.title} />
|
||||
</label>
|
||||
<span className="bo-grow" style={{ minWidth: 0 }}>
|
||||
<b>{x.title}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{x.lead ? (
|
||||
<a href={`#/crm/${x.lead.id}`}>{x.lead.name}</a>
|
||||
) : (
|
||||
t('crm.noLead')
|
||||
)}
|
||||
{isSuper && ` · ${x.assignee.name}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="bo-tasks__due">{overdue ? `${t('crm.overdue')} · ` : ''}{dateTime(x.dueAt)}</span>
|
||||
{x.lead?.phone && (
|
||||
<a className="bo-iconbtn" href={`tel:${x.lead.phone.replace(/[^\d+]/g, '')}`} aria-label={t('ord.call')} title={t('ord.call')}>
|
||||
<I name="phone" size={18} />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 12 }}>
|
||||
{t('crm.tasksHint')}
|
||||
</p>
|
||||
{adding && <TaskDialog onClose={() => setAdding(false)} onDone={refresh} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDialog({ leadId, onClose, onDone }: { leadId?: string; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const tomorrow = new Date(Date.now() + DAY + 4 * 3600_000).toISOString().slice(0, 10);
|
||||
const [title, setTitle] = useState('');
|
||||
const [day, setDay] = useState(tomorrow);
|
||||
const [time, setTime] = useState('11:00');
|
||||
const add = useMutation(
|
||||
trpc.bo.crm.addTask.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const presets = [t('crm.tp.call'), t('crm.tp.meet'), t('crm.tp.send'), t('crm.tp.check')];
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('crm.addTask')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!title.trim() || add.isPending} onClick={() => add.mutate({ leadId, title, dueAt: new Date(`${day}T${time}:00+04:00`) })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('crm.taskTitle')}>
|
||||
<input className="bo-input" autoFocus value={title} maxLength={200} onChange={(e) => setTitle(e.target.value)} />
|
||||
</Field>
|
||||
<div className="bo-pills">
|
||||
{presets.map((p) => (
|
||||
<button key={p} type="button" className="bo-pill" onClick={() => setTitle(p)}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.taskDay')}>
|
||||
<input className="bo-input" type="date" value={day} onChange={(e) => setDay(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('crm.taskTime')}>
|
||||
<input className="bo-input" type="time" value={time} step={900} onChange={(e) => setTime(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<p className="bo-hint">{t('crm.taskHint')}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── карта ─────
|
||||
|
||||
function CrmMap() {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const data = useQuery(trpc.bo.crm.map.queryOptions());
|
||||
const points = useMemo<MapPoint[]>(
|
||||
() => [
|
||||
...(data.data?.leads ?? []).map((l) => ({ id: `l:${l.id}`, lat: l.lat, lng: l.lng, title: l.name, sub: l.stage, color: STAGE_COLORS[l.color] ?? STAGE_COLORS.blue!, dim: l.lost })),
|
||||
...(data.data?.partners ?? []).map((p) => ({ id: `p:${p.id}`, lat: p.lat, lng: p.lng, title: p.name, sub: t('crm.partnerOnMap'), color: '#b8235a', strong: true, dim: !p.active })),
|
||||
],
|
||||
[data.data, t],
|
||||
);
|
||||
return (
|
||||
<section className="bo-panel" style={{ padding: 10 }}>
|
||||
<MapView points={points} height={560} onPick={(id) => navigate(id.startsWith('l:') ? `/crm/${id.slice(2)}` : `/venues/${id.slice(2)}`)} />
|
||||
<div className="bo-maplegend">
|
||||
<span>
|
||||
<i style={{ background: '#b8235a', borderColor: '#4a1028' }} /> {t('crm.legendPartner')}
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: STAGE_COLORS.blue }} /> {t('crm.legendLead')}
|
||||
</span>
|
||||
<span className="bo-faint">{t('crm.mapHint')}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── диалоги ─────
|
||||
|
||||
export function LeadDialog({ me, lead, onClose }: { me: Me; lead?: { id: string; kind: Kind; name: string; category: string | null; contactName: string | null; phone: string | null; telegram: string | null; address: string | null; note: string | null; owner: { id: string } | null }; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const team = useQuery(trpc.bo.crm.team.queryOptions());
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const [f, setF] = useState({
|
||||
kind: (lead?.kind ?? 'VENUE') as Kind,
|
||||
name: lead?.name ?? '',
|
||||
category: lead?.category ?? 'cafe',
|
||||
contactName: lead?.contactName ?? '',
|
||||
phone: lead?.phone ?? '',
|
||||
telegram: lead?.telegram ?? '',
|
||||
address: lead?.address ?? '',
|
||||
note: lead?.note ?? '',
|
||||
ownerId: lead?.owner?.id ?? me.id,
|
||||
});
|
||||
const done = (r?: { id: string } | void) => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.get.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
|
||||
onClose();
|
||||
if (r && 'id' in r) navigate(`/crm/${r.id}`);
|
||||
};
|
||||
const onError = (e: unknown) => toast(errText(t, e), 'error');
|
||||
const create = useMutation(trpc.bo.crm.create.mutationOptions({ onSuccess: done, onError }));
|
||||
const update = useMutation(trpc.bo.crm.update.mutationOptions({ onSuccess: () => done(), onError }));
|
||||
const payload = {
|
||||
name: f.name,
|
||||
category: f.kind === 'VENUE' ? (f.category as (typeof VENUE_CATEGORIES)[number]) : null,
|
||||
contactName: f.contactName || null,
|
||||
phone: f.phone || null,
|
||||
telegram: f.telegram || null,
|
||||
address: f.address || null,
|
||||
note: f.note || null,
|
||||
...(isSuper ? { ownerId: f.ownerId || null } : {}),
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={lead ? t('crm.editLead') : t('crm.newLead')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!f.name.trim() || create.isPending || update.isPending} onClick={() => (lead ? update.mutate({ id: lead.id, ...payload }) : create.mutate({ kind: f.kind, ...payload }))}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!lead && (
|
||||
<div className="bo-chtabs">
|
||||
{(['VENUE', 'AGENCY'] as const).map((k) => (
|
||||
<button key={k} type="button" aria-pressed={f.kind === k} onClick={() => setF({ ...f, kind: k })}>
|
||||
{t(`crm.kind1.${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.name')}>
|
||||
<input className="bo-input" autoFocus value={f.name} maxLength={120} onChange={(e) => setF({ ...f, name: e.target.value })} placeholder={f.kind === 'VENUE' ? t('crm.namePhVenue') : t('crm.namePhAgency')} />
|
||||
</Field>
|
||||
{f.kind === 'VENUE' ? (
|
||||
<Field label={t('crm.category')}>
|
||||
<select className="bo-select" value={f.category} onChange={(e) => setF({ ...f, category: e.target.value })}>
|
||||
{VENUE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`venue.cat.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.contactName')}>
|
||||
<input className="bo-input" value={f.contactName} maxLength={120} onChange={(e) => setF({ ...f, contactName: e.target.value })} placeholder={t('crm.contactPh')} />
|
||||
</Field>
|
||||
<Field label={t('chat.phone')}>
|
||||
<input className="bo-input" value={f.phone} maxLength={40} onChange={(e) => setF({ ...f, phone: e.target.value })} placeholder="+374 …" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label="Telegram">
|
||||
<input className="bo-input" value={f.telegram} maxLength={80} onChange={(e) => setF({ ...f, telegram: e.target.value })} placeholder="@username" />
|
||||
</Field>
|
||||
<Field label={t('crm.address')}>
|
||||
<input className="bo-input" value={f.address} maxLength={200} onChange={(e) => setF({ ...f, address: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
{isSuper && (
|
||||
<Field label={t('crm.owner')}>
|
||||
<select className="bo-select" value={f.ownerId} onChange={(e) => setF({ ...f, ownerId: e.target.value })}>
|
||||
<option value="">{t('crm.noOwner')}</option>
|
||||
{team.data?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t('crm.note')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={f.note} maxLength={4000} onChange={(e) => setF({ ...f, note: e.target.value })} />
|
||||
</Field>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** «Подключён»: создаём партнёра/агентство и показываем ссылку, которую нужно отправить */
|
||||
export function ConvertDialog({ leadId, canOpen, onClose, onDone }: { leadId: string; canOpen: boolean; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const lead = useQuery(trpc.bo.crm.get.queryOptions({ id: leadId }));
|
||||
const [code, setCode] = useState('');
|
||||
const [result, setResult] = useState<{ kind: 'VENUE' | 'AGENCY'; url: string; id: string } | null>(null);
|
||||
const convert = useMutation(
|
||||
trpc.bo.crm.convert.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
onDone();
|
||||
setResult({ kind: r.kind, url: r.url, id: r.kind === 'VENUE' ? r.partnerId : r.agencyId });
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const isAgency = lead.data?.kind === 'AGENCY';
|
||||
useEffect(() => {
|
||||
if (lead.data && !code) setCode(lead.data.name.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lead.data]);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={result ? t('crm.convertedTitle') : t('crm.convertTitle', { name: lead.data?.name ?? '' })}
|
||||
footer={
|
||||
result ? (
|
||||
<>
|
||||
{canOpen && (
|
||||
<a className="bo-btn bo-btn--ghost" href={result.kind === 'VENUE' ? `#/venues/${result.id}` : `#/agencies/${result.id}`} onClick={onClose}>
|
||||
{result.kind === 'VENUE' ? t('crm.openVenue') : t('crm.openAgency')}
|
||||
</a>
|
||||
)}
|
||||
<button type="button" className="bo-btn" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--accent" disabled={!lead.data || (isAgency && !code.trim()) || convert.isPending} onClick={() => convert.mutate({ id: leadId, agencyCode: isAgency ? code : undefined })}>
|
||||
{t('crm.convert')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<>
|
||||
<p style={{ margin: 0 }}>{result.kind === 'VENUE' ? t('crm.convertedVenue') : t('crm.convertedAgency')}</p>
|
||||
<div className="bo-linkbox">
|
||||
<code>{result.url}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(result.url);
|
||||
toast(t('ui.copied'));
|
||||
}}
|
||||
>
|
||||
{t('ui.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : isAgency ? (
|
||||
<>
|
||||
<p className="bo-hint">{t('crm.convertAgencyHint')}</p>
|
||||
<Field label={t('crm.agencyCode')} hint={t('crm.agencyCodeHint')}>
|
||||
<input className="bo-input" value={code} maxLength={32} onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<p className="bo-hint">{t('crm.convertVenueHint')}</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function LostDialog({ leadId, stageId, onClose, onDone }: { leadId: string; stageId: string; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [reason, setReason] = useState('');
|
||||
const move = useMutation(
|
||||
trpc.bo.crm.move.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const presets = [t('crm.lr.price'), t('crm.lr.notNow'), t('crm.lr.noAnswer'), t('crm.lr.competitor')];
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('crm.lostTitle')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!reason.trim() || move.isPending} onClick={() => move.mutate({ id: leadId, stageId, lostReason: reason })}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label={t('crm.lostReason')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||
</Field>
|
||||
<div className="bo-pills">
|
||||
{presets.map((p) => (
|
||||
<button key={p} type="button" className="bo-pill" onClick={() => setReason(p)}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function StagesDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
|
||||
const [list, setList] = useState<{ id?: string; name: string; kind: 'OPEN' | 'WON' | 'LOST'; color: string }[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (stages.data && !list) setList(stages.data.map((s) => ({ id: s.id, name: s.name, kind: s.kind, color: s.color })));
|
||||
}, [stages.data, list]);
|
||||
const save = useMutation(
|
||||
trpc.bo.crm.saveStages.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.stages.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const swap = (i: number, j: number) => {
|
||||
if (!list || j < 0 || j >= list.length) return;
|
||||
const next = [...list];
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
setList(next);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={t('crm.stagesTitle')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => list && setList([...list, { name: '', kind: 'OPEN', color: 'blue' }])}>
|
||||
<I name="plus" size={16} />
|
||||
{t('crm.addStage')}
|
||||
</button>
|
||||
<span className="bo-grow" />
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn" disabled={!list || list.some((s) => !s.name.trim()) || save.isPending} onClick={() => list && save.mutate(list)}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="bo-hint">{t('crm.stagesHint')}</p>
|
||||
<ul className="bo-stages">
|
||||
{(list ?? []).map((s, i) => (
|
||||
<li key={s.id ?? `new-${i}`}>
|
||||
<span className="bo-stages__arrows">
|
||||
<button type="button" onClick={() => swap(i, i - 1)} aria-label="↑">
|
||||
<I name="arrowUp" size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => swap(i, i + 1)} aria-label="↓">
|
||||
<I name="arrowDown" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
<input className="bo-input" value={s.name} maxLength={40} onChange={(e) => setList(list!.map((x, k) => (k === i ? { ...x, name: e.target.value } : x)))} />
|
||||
<select className="bo-select" value={s.kind} onChange={(e) => setList(list!.map((x, k) => (k === i ? { ...x, kind: e.target.value as 'OPEN' } : x)))}>
|
||||
{(['OPEN', 'WON', 'LOST'] as const).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{t(`crm.stageKind.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="bo-swatches">
|
||||
{STAGE_COLOR_KEYS.map((c) => (
|
||||
<button key={c} type="button" aria-pressed={s.color === c} style={{ background: STAGE_COLORS[c] }} onClick={() => setList(list!.map((x, k) => (k === i ? { ...x, color: c } : x)))} aria-label={c} />
|
||||
))}
|
||||
</span>
|
||||
<button type="button" className="bo-iconbtn" onClick={() => setList(list!.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
|
||||
<I name="close" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── бонус ─────
|
||||
|
||||
function BonusChip({ me, salesId, onEdit, configured }: { me: Me; salesId?: string; onEdit?: () => void; configured?: boolean }) {
|
||||
const { t, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const who = isSuper ? salesId : undefined;
|
||||
const bonus = useQuery({ ...trpc.bo.crm.bonus.queryOptions({ month: monthKey(), salesId: who === 'none' ? undefined : who }) });
|
||||
if (!bonus.data) return null;
|
||||
const b = bonus.data;
|
||||
const title = [t('crm.bonusVenues', { n: b.venues, sum: amd(b.fromVenues) }), t('crm.bonusAgencies', { n: b.agencies, sum: amd(b.fromAgencies) }), t('crm.bonusRevenue', { pct: b.rules.pct, sum: amd(b.fromRevenue) })].join('\n');
|
||||
return (
|
||||
<button type="button" className="bo-bonus" title={title} onClick={onEdit} disabled={!onEdit}>
|
||||
<span className="bo-small">{isSuper && !salesId ? t('crm.bonusMineSuper') : t('crm.bonusMonth')}</span>
|
||||
<b className="bo-num">{configured === false ? t('crm.bonusNotSet') : amd(b.total)}</b>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BonusDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const rules = useQuery(trpc.bo.crm.bonusRules.queryOptions());
|
||||
const [f, setF] = useState<{ perVenue: string; perAgency: string; pct: string; months: string } | null>(null);
|
||||
useEffect(() => {
|
||||
if (rules.data && !f) setF({ perVenue: String(rules.data.perVenue), perAgency: String(rules.data.perAgency), pct: String(rules.data.pct), months: String(rules.data.months) });
|
||||
}, [rules.data, f]);
|
||||
const save = useMutation(
|
||||
trpc.bo.crm.setBonusRules.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.bonus.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.bonusRules.queryKey() });
|
||||
toast(t('prv.saved'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const num = (v: string) => v.replace(/\D/g, '');
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={t('crm.bonusTitle')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!f || save.isPending}
|
||||
onClick={() => f && save.mutate({ perVenue: Number(f.perVenue) || 0, perAgency: Number(f.perAgency) || 0, pct: Math.min(100, Number(f.pct) || 0), months: Math.max(1, Number(f.months) || 1) })}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{f && (
|
||||
<>
|
||||
<p className="bo-hint">{t('crm.bonusHint')}</p>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.bonusPerVenue')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.perVenue} onChange={(e) => setF({ ...f, perVenue: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('crm.bonusPerAgency')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.perAgency} onChange={(e) => setF({ ...f, perAgency: num(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.bonusPct')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.pct} onChange={(e) => setF({ ...f, pct: num(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label={t('crm.bonusMonths')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.months} onChange={(e) => setF({ ...f, months: num(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { Me } from '../App.tsx';
|
||||
import { errText, useT } from '../i18n.tsx';
|
||||
import { Avatar, I, Skeleton } from '../components/kit.tsx';
|
||||
import { MapView, parseCoords, STAGE_COLORS } from '../components/map.tsx';
|
||||
import { ConvertDialog, LeadDialog, LostDialog, TaskDialog } from './Crm.tsx';
|
||||
import { CopyLink } from './Agencies.tsx';
|
||||
|
||||
type ActType = 'CALL' | 'MEETING' | 'NOTE';
|
||||
const ACT_ICON: Record<string, 'phone' | 'clients' | 'journal' | 'file' | 'swap' | 'check'> = { CALL: 'phone', MEETING: 'clients', NOTE: 'journal', FILE: 'file', STAGE: 'swap', WON: 'check' };
|
||||
|
||||
export function LeadCard({ id, me }: { id: string; me: Me }) {
|
||||
const { t, date, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const lead = useQuery(trpc.bo.crm.get.queryOptions({ id }));
|
||||
const stages = useQuery(trpc.bo.crm.stages.queryOptions());
|
||||
const [dlg, setDlg] = useState<'edit' | 'task' | 'convert' | { lost: string } | null>(null);
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.get.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.board.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.summary.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.crm.tasks.queryKey() });
|
||||
};
|
||||
const onError = (e: unknown) => toast(errText(t, e), 'error');
|
||||
const move = useMutation(trpc.bo.crm.move.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const toggleTask = useMutation(trpc.bo.crm.setTaskDone.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const update = useMutation(trpc.bo.crm.update.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const remove = useMutation(trpc.bo.crm.remove.mutationOptions({ onSuccess: () => navigate('/crm'), onError }));
|
||||
const [coords, setCoords] = useState('');
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
|
||||
const back = (
|
||||
<a className="bo-back" href="#/crm">
|
||||
<I name="back" size={16} />
|
||||
{t('crm.back')}
|
||||
</a>
|
||||
);
|
||||
if (lead.isError)
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
|
||||
</>
|
||||
);
|
||||
if (!lead.data || !stages.data)
|
||||
return (
|
||||
<div className="bo-col">
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={300} />
|
||||
</div>
|
||||
);
|
||||
const d = lead.data;
|
||||
const converted = !!(d.partner || d.agency);
|
||||
const tel = d.phone?.replace(/[^\d+]/g, '');
|
||||
const tgName = d.telegram?.replace(/^@/, '').replace(/^https?:\/\/t\.me\//, '');
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px', alignItems: 'center' }}>
|
||||
<span className={`bo-chip bo-chip--${d.kind === 'AGENCY' ? 'SUPPORT' : 'provider'}`}>{t(`crm.kind1.${d.kind}`)}</span>
|
||||
{d.category && <span>{t(`venue.cat.${d.category}`)}</span>}
|
||||
<span>{d.owner ? t('crm.ownerIs', { name: d.owner.name }) : t('crm.noOwner')}</span>
|
||||
<span>{t('crm.created', { date: date(d.createdAt) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{tel && (
|
||||
<a className="bo-btn" href={`tel:${tel}`}>
|
||||
<I name="phone" size={16} />
|
||||
{t('ord.call')}
|
||||
</a>
|
||||
)}
|
||||
{tgName && (
|
||||
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">
|
||||
<I name="telegram" size={16} />
|
||||
Telegram
|
||||
</a>
|
||||
)}
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setDlg('edit')}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* этапы: клик — перевести */}
|
||||
<ol className="bo-steps bo-steps--click" aria-label={t('crm.stage')}>
|
||||
{stages.data.map((s) => {
|
||||
const cur = s.id === d.stageId;
|
||||
return (
|
||||
<li key={s.id} data-state={cur ? 'now' : 'next'} style={cur ? { borderTopColor: STAGE_COLORS[s.color], background: STAGE_COLORS[s.color] } : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cur || move.isPending}
|
||||
onClick={() => {
|
||||
if (s.kind === 'WON' && !converted) return setDlg('convert');
|
||||
if (s.kind === 'LOST') return setDlg({ lost: s.id });
|
||||
move.mutate({ id, stageId: s.id });
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{d.lostReason && <div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>{t('crm.lostBanner', { reason: d.lostReason })}</div>}
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<Composer leadId={id} onDone={refresh} />
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('crm.history')}</h2>
|
||||
</div>
|
||||
{d.activities.length === 0 ? (
|
||||
<p className="bo-hint">{t('crm.noHistory')}</p>
|
||||
) : (
|
||||
<ol className="bo-acts">
|
||||
{d.activities.map((a) => (
|
||||
<li key={a.id} data-type={a.type}>
|
||||
<span className="bo-acts__icon">
|
||||
<I name={ACT_ICON[a.type] ?? 'journal'} size={16} />
|
||||
</span>
|
||||
<span className="bo-grow" style={{ minWidth: 0 }}>
|
||||
<span className="bo-small bo-faint">
|
||||
{t(`crm.act.${a.type}`)} · {a.author ?? '—'} · {dateTime(a.at)}
|
||||
</span>
|
||||
{a.text && <span className="bo-acts__text">{a.text}</span>}
|
||||
{a.file &&
|
||||
(a.file.mime?.startsWith('image/') ? (
|
||||
<a href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
|
||||
<img className="bo-acts__img" src={`/files/bo/lead-file/${a.id}`} alt={a.file.name ?? ''} loading="lazy" />
|
||||
</a>
|
||||
) : (
|
||||
<a className="bo-msg__file" style={{ margin: '6px 0 0', background: 'var(--bo-soft)' }} href={`/files/bo/lead-file/${a.id}`} target="_blank" rel="noreferrer">
|
||||
<I name="file" size={20} />
|
||||
<span className="bo-msg__fname">{a.file.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
<section className={`bo-panel${converted ? ' bo-wonbox' : ''}`}>
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{converted ? t('crm.connected') : t('crm.connect')}</h2>
|
||||
</div>
|
||||
{d.partner ? (
|
||||
<>
|
||||
<p className="bo-hint">{d.partner.ownerLinked ? t('crm.ownerLinked') : t('crm.ownerNotYet')}</p>
|
||||
{!d.partner.ownerLinked && d.partner.ownerInviteUrl && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<CopyLink url={d.partner.ownerInviteUrl} />
|
||||
</div>
|
||||
)}
|
||||
{isSuper && (
|
||||
<a className="bo-btn bo-btn--sm" href={`#/venues/${d.partner.id}`}>
|
||||
{t('crm.openVenue')}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : d.agency ? (
|
||||
<>
|
||||
<p className="bo-hint">{t('crm.agencyInfo', { code: d.agency.code, n: d.agency.clients })}</p>
|
||||
{isSuper && (
|
||||
<a className="bo-btn bo-btn--sm" href={`#/agencies/${d.agency.id}`}>
|
||||
{t('crm.openAgency')}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="bo-hint">{d.kind === 'VENUE' ? t('crm.convertVenueHint') : t('crm.convertAgencyHint')}</p>
|
||||
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setDlg('convert')}>
|
||||
<I name="check" size={16} />
|
||||
{t('crm.convert')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('crm.tasks')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('task')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('crm.addTask')}
|
||||
</button>
|
||||
</div>
|
||||
{d.tasks.length === 0 ? (
|
||||
<p className="bo-hint">{t('crm.noLeadTasks')}</p>
|
||||
) : (
|
||||
<ul className="bo-tasks">
|
||||
{d.tasks.map((x) => {
|
||||
const overdue = !x.doneAt && x.dueAt.getTime() < now;
|
||||
return (
|
||||
<li key={x.id} data-overdue={overdue || undefined} data-done={!!x.doneAt || undefined}>
|
||||
<label className="bo-tasks__check">
|
||||
<input type="checkbox" checked={!!x.doneAt} onChange={(e) => toggleTask.mutate({ id: x.id, done: e.target.checked })} aria-label={x.title} />
|
||||
</label>
|
||||
<span className="bo-grow">
|
||||
<b>{x.title}</b>
|
||||
</span>
|
||||
<span className="bo-tasks__due">{dateTime(x.dueAt)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('crm.contacts')}</h2>
|
||||
</div>
|
||||
<dl className="bo-kv">
|
||||
<dt>{t('crm.contactName')}</dt>
|
||||
<dd>{d.contactName ?? '—'}</dd>
|
||||
<dt>{t('chat.phone')}</dt>
|
||||
<dd>{d.phone ? <a href={`tel:${tel}`}>{d.phone}</a> : '—'}</dd>
|
||||
<dt>Telegram</dt>
|
||||
<dd>{tgName ? <a href={`https://t.me/${tgName}`} target="_blank" rel="noreferrer">@{tgName}</a> : '—'}</dd>
|
||||
<dt>{t('crm.address')}</dt>
|
||||
<dd>{d.address ?? '—'}</dd>
|
||||
</dl>
|
||||
{d.note && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.note}</p>}
|
||||
{d.kind === 'VENUE' && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{d.lat != null && d.lng != null ? (
|
||||
<MapView points={[{ id: d.id, lat: d.lat, lng: d.lng, title: d.name, color: '#b8235a', strong: true }]} height={200} focus={[d.lat, d.lng]} />
|
||||
) : (
|
||||
<p className="bo-hint">{t('crm.noCoords')}</p>
|
||||
)}
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 8 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const c = parseCoords(coords);
|
||||
if (!c) return toast(t('crm.coordsBad'), 'error');
|
||||
update.mutate({ id, lat: c.lat, lng: c.lng });
|
||||
setCoords('');
|
||||
}}
|
||||
>
|
||||
<input className="bo-input" value={coords} onChange={(e) => setCoords(e.target.value)} placeholder={t('crm.coordsPh')} style={{ minHeight: 36, padding: '6px 12px' }} />
|
||||
<button type="submit" className="bo-btn bo-btn--sm" disabled={!coords.trim()}>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!converted && (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', alignSelf: 'flex-start' }} onClick={() => confirm(t('crm.removeConfirm')) && remove.mutate({ id })}>
|
||||
{t('crm.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dlg === 'edit' && <LeadDialog me={me} lead={d} onClose={() => setDlg(null)} />}
|
||||
{dlg === 'task' && <TaskDialog leadId={id} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg === 'convert' && <ConvertDialog leadId={id} canOpen={isSuper} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg && typeof dlg === 'object' && <LostDialog leadId={id} stageId={dlg.lost} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Записать контакт: звонок / встреча / заметка, можно с фото или файлом */
|
||||
function Composer({ leadId, onDone }: { leadId: string; onDone: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [type, setType] = useState<ActType>('CALL');
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const add = useMutation(
|
||||
trpc.bo.crm.addActivity.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setText('');
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const upload = async (f: File) => {
|
||||
if (f.size > 10 * 1024 * 1024) return toast(t('chat.fileTooBig'), 'error');
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('text', text);
|
||||
form.set('file', f);
|
||||
const res = await fetch(`/files/bo/lead/${leadId}`, { method: 'POST', body: form, credentials: 'include' });
|
||||
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
|
||||
setText('');
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast(errText(t, e), 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-composer__tabs" role="tablist">
|
||||
{(['CALL', 'MEETING', 'NOTE'] as const).map((x) => (
|
||||
<button key={x} type="button" role="tab" aria-selected={type === x} onClick={() => setType(x)}>
|
||||
<I name={ACT_ICON[x]!} size={13} />
|
||||
{t(`crm.act.${x}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 76 }} value={text} maxLength={4000} onChange={(e) => setText(e.target.value)} placeholder={t(`crm.actPh.${type}`)} />
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--sm" disabled={!text.trim() || add.isPending} onClick={() => add.mutate({ id: leadId, type, text })}>
|
||||
{t('crm.addAct')}
|
||||
</button>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={busy} onClick={() => file.current?.click()}>
|
||||
<I name="clip" size={16} />
|
||||
{t('crm.attach')}
|
||||
</button>
|
||||
<input
|
||||
ref={file}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (f) void upload(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { errText, useT, type BoLocale } from '../i18n.tsx';
|
||||
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
|
||||
import { MapView, parseCoords } from '../components/map.tsx';
|
||||
import { CopyLink } from './Agencies.tsx';
|
||||
import { VENUE_CATEGORIES } from './Crm.tsx';
|
||||
|
||||
type L5 = { ru: string; en: string; zh: string; hy: string; es: string };
|
||||
const EMPTY: L5 = { ru: '', en: '', zh: '', hy: '', es: '' };
|
||||
const pickLang = (v: Record<string, string> | null | undefined, l: BoLocale) => (v ? v[l] || v.ru || v.en || '' : '');
|
||||
|
||||
export function Venues() {
|
||||
const { t, date } = useT();
|
||||
const trpc = useTRPC();
|
||||
const list = useQuery(trpc.bo.partners.venues.queryOptions());
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="bo-head">
|
||||
<h1 className="bo-title">{t('vn.title')}</h1>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('vn.add')}
|
||||
</button>
|
||||
</div>
|
||||
{!list.data ? (
|
||||
<Skeleton h={240} />
|
||||
) : list.data.length === 0 ? (
|
||||
<div className="bo-panel bo-empty">{t('vn.empty')}</div>
|
||||
) : (
|
||||
<div className="bo-panel" style={{ padding: 8 }}>
|
||||
<table className="bo-table bo-ptable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('vn.col.name')}</th>
|
||||
<th className="bo-r">{t('vn.col.offers')}</th>
|
||||
<th className="bo-r">{t('vn.col.staff')}</th>
|
||||
<th className="bo-r">{t('vn.col.visits')}</th>
|
||||
<th>{t('vn.col.last')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.map((v) => (
|
||||
<tr key={v.id} onClick={() => navigate(`/venues/${v.id}`)} data-off={!v.active || undefined}>
|
||||
<td>
|
||||
<a href={`#/venues/${v.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar name={v.name} id={v.id} size={38} />
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="bo-who__name">{v.name}</span>
|
||||
<span className="bo-who__meta">{[t(`venue.cat.${v.category}`), v.address, v.sales?.name].filter(Boolean).join(' · ')}</span>
|
||||
<span className="bo-tags" style={{ marginTop: 4 }}>
|
||||
{!v.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
{v.members === 0 && <span className="bo-chip bo-chip--warn">{t('vn.noOwner')}</span>}
|
||||
{v.offers === 0 && <span className="bo-chip">{t('vn.noOffers')}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="bo-r bo-num">{v.offers}</td>
|
||||
<td className="bo-r bo-num">{v.members}</td>
|
||||
<td className="bo-r bo-num">{v.visits30}</td>
|
||||
<td className="bo-small">{v.lastVisit ? date(v.lastVisit) : <span className="bo-faint">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
|
||||
{t('vn.hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{creating && <VenueDialog onClose={() => setCreating(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function VenueCard({ id }: { id: string }) {
|
||||
const { t, locale, date, dateTime } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const v = useQuery(trpc.bo.partners.venue.queryOptions({ id }));
|
||||
const [dlg, setDlg] = useState<'edit' | { offer?: NonNullable<typeof v.data>['offers'][number] } | null>(null);
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.venue.queryKey({ id }) });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.venues.queryKey() });
|
||||
};
|
||||
const onError = (e: unknown) => toast(errText(t, e), 'error');
|
||||
const invite = useMutation(trpc.bo.partners.ownerInvite.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const removeMember = useMutation(trpc.bo.partners.removeMember.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const points = useMemo(() => (v.data?.lat != null && v.data.lng != null ? [{ id: v.data.id, lat: v.data.lat, lng: v.data.lng, title: v.data.name, color: '#b8235a', strong: true }] : []), [v.data]);
|
||||
|
||||
const back = (
|
||||
<a className="bo-back" href="#/venues">
|
||||
<I name="back" size={16} />
|
||||
{t('vn.back')}
|
||||
</a>
|
||||
);
|
||||
if (!v.data)
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{v.isError ? <div className="bo-panel bo-empty">{errText(t, v.error)}</div> : <Skeleton h={300} />}
|
||||
</>
|
||||
);
|
||||
const d = v.data;
|
||||
const maxWeek = Math.max(1, ...d.weeks.map((w) => w.checks + w.redeemed));
|
||||
const hasOwner = d.members.some((m) => m.role === 'OWNER');
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<header className="bo-profile">
|
||||
<Avatar name={d.name} id={d.id} size={64} />
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
|
||||
{d.name}
|
||||
</h1>
|
||||
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span>{t(`venue.cat.${d.category}`)}</span>
|
||||
{d.address && <span>{d.address}</span>}
|
||||
{d.phone && <a href={`tel:${d.phone.replace(/[^\d+]/g, '')}`}>{d.phone}</a>}
|
||||
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
|
||||
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
|
||||
</div>
|
||||
{pickLang(d.offer, locale) && <p style={{ margin: '10px 0 0' }}>{pickLang(d.offer, locale)}</p>}
|
||||
</div>
|
||||
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{!hasOwner && (
|
||||
<div className="bo-banner">
|
||||
<span>{t('vn.ownerHint')}</span>
|
||||
{d.ownerInviteUrl ? (
|
||||
<CopyLink url={d.ownerInviteUrl} />
|
||||
) : (
|
||||
<button type="button" className="bo-btn bo-btn--sm" onClick={() => invite.mutate({ id })} disabled={invite.isPending}>
|
||||
{t('vn.makeLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bo-client-grid">
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('vn.offers')}</h2>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({})}>
|
||||
<I name="plus" size={16} />
|
||||
{t('vn.addOffer')}
|
||||
</button>
|
||||
</div>
|
||||
{d.offers.length === 0 ? (
|
||||
<p className="bo-hint">{t('vn.noOffersHint')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.offers.map((o) => (
|
||||
<li key={o.id} data-off={!o.active || undefined} style={{ opacity: o.active ? 1 : 0.5 }}>
|
||||
<PlanBadge plan={o.minLevel} t={t} />
|
||||
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
|
||||
<b>{pickLang(o.title, locale)}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{o.minLevel ? t(`vn.level.${o.minLevel}`) : t('vn.level.any')} · {t(`vn.limit.${o.limit}`, { n: o.limitCount })}
|
||||
{!o.active && ` · ${t('vn.offerOff')}`}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg({ offer: o })}>
|
||||
{t('prv.edit')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('vn.activity')}</h2>
|
||||
<span className="bo-faint bo-small">{t('vn.weeks8')}</span>
|
||||
</div>
|
||||
<div className="bo-weeks">
|
||||
{d.weeks.map((w) => (
|
||||
<div key={w.from.toISOString()} title={`${date(w.from)}: ${w.checks} / ${w.redeemed}`}>
|
||||
<span className="bo-weeks__bar" style={{ height: `${((w.checks + w.redeemed) / maxWeek) * 100}%` }}>
|
||||
<span style={{ height: `${w.checks + w.redeemed ? (w.redeemed / (w.checks + w.redeemed)) * 100 : 0}%` }} />
|
||||
</span>
|
||||
<span className="bo-small bo-faint">{w.checks + w.redeemed}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bo-chart__legend" style={{ marginTop: 8, color: 'var(--bc-ink-2)', justifyContent: 'flex-start', gap: 18 }}>
|
||||
<span className="bo-key" style={{ ['--key' as string]: 'var(--bc-blue)' }}>{t('vn.checks')}</span>
|
||||
<span className="bo-key" style={{ ['--key' as string]: 'var(--bc-accent)' }}>{t('vn.redeemed')}</span>
|
||||
</div>
|
||||
{d.recent.length > 0 && (
|
||||
<ul className="bo-list" style={{ marginTop: 12 }}>
|
||||
{d.recent.slice(0, 12).map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="bo-feed__time">{dateTime(u.at)}</span>
|
||||
<a href={`#/clients/${u.clientId}`} className="bo-grow" style={{ color: 'inherit' }}>
|
||||
{u.client}
|
||||
<span className="bo-small bo-muted">
|
||||
{' '}
|
||||
· {u.offer ? pickLang(u.offer, locale) : t('vn.cardCheck')}
|
||||
{u.by && ` · ${u.by}`}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="bo-col">
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('vn.staff')}</h2>
|
||||
{hasOwner && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => invite.mutate({ id })} disabled={invite.isPending} title={t('vn.newOwnerLinkHint')}>
|
||||
{t('vn.newOwnerLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{d.members.length === 0 ? (
|
||||
<p className="bo-hint">{t('vn.noStaff')}</p>
|
||||
) : (
|
||||
<ul className="bo-list">
|
||||
{d.members.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="bo-grow">
|
||||
<b>{m.name}</b>
|
||||
<span className="bo-small bo-muted" style={{ display: 'block' }}>
|
||||
{t(`vn.role.${m.role}`)}
|
||||
{m.username && ` · @${m.username}`} · {date(m.since)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="bo-iconbtn" onClick={() => confirm(t('vn.removeStaff', { name: m.name })) && removeMember.mutate({ id: m.id })} aria-label={t('chat.tplDelete')}>
|
||||
<I name="close" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hasOwner && d.ownerInviteUrl && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<CopyLink url={d.ownerInviteUrl} />
|
||||
</div>
|
||||
)}
|
||||
<p className="bo-hint" style={{ marginTop: 10 }}>
|
||||
{t('vn.staffHint')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="bo-panel" style={{ padding: 10 }}>
|
||||
{points.length ? <MapView points={points} height={220} focus={[d.lat!, d.lng!]} /> : <p className="bo-hint" style={{ padding: 12 }}>{t('crm.noCoords')}</p>}
|
||||
</section>
|
||||
{d.note && <p className="bo-side-panel__note">{d.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dlg === 'edit' && <VenueDialog venue={d} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
{dlg && typeof dlg === 'object' && <OfferDialog partnerId={id} offer={dlg.offer} onClose={() => setDlg(null)} onDone={refresh} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VenueDialog({ venue, onClose, onDone }: { venue?: { id: string; name: string; category: string; offer: Record<string, string>; address: string | null; phone: string | null; lat: number | null; lng: number | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const team = useQuery(trpc.bo.partners.salesTeam.queryOptions());
|
||||
const [f, setF] = useState({
|
||||
name: venue?.name ?? '',
|
||||
category: venue?.category ?? 'cafe',
|
||||
offer: { ...EMPTY, ...(venue?.offer ?? {}) } as L5,
|
||||
address: venue?.address ?? '',
|
||||
phone: venue?.phone ?? '',
|
||||
coords: venue?.lat != null && venue.lng != null ? `${venue.lat}, ${venue.lng}` : '',
|
||||
note: venue?.note ?? '',
|
||||
active: venue?.active ?? true,
|
||||
salesId: venue?.sales?.id ?? '',
|
||||
});
|
||||
const save = useMutation(
|
||||
trpc.bo.partners.saveVenue.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.partners.venues.queryKey() });
|
||||
onDone?.();
|
||||
onClose();
|
||||
if (!venue) navigate(`/venues/${r.id}`);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const coords = f.coords.trim() ? parseCoords(f.coords) : null;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={venue ? t('vn.editTitle') : t('vn.add')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!f.name.trim() || (!!f.coords.trim() && !coords) || save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
id: venue?.id,
|
||||
name: f.name,
|
||||
category: f.category as (typeof VENUE_CATEGORIES)[number],
|
||||
offer: f.offer,
|
||||
address: f.address || null,
|
||||
phone: f.phone || null,
|
||||
lat: coords?.lat ?? null,
|
||||
lng: coords?.lng ?? null,
|
||||
note: f.note || null,
|
||||
active: f.active,
|
||||
salesId: f.salesId || null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.name')}>
|
||||
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('crm.category')}>
|
||||
<select className="bo-select" value={f.category} onChange={(e) => setF({ ...f, category: e.target.value })}>
|
||||
{VENUE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`venue.cat.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<L5Fields label={t('vn.blurb')} hint={t('vn.blurbHint')} value={f.offer} onChange={(offer) => setF({ ...f, offer })} />
|
||||
<div className="bo-row2">
|
||||
<Field label={t('crm.address')}>
|
||||
<input className="bo-input" value={f.address} maxLength={200} onChange={(e) => setF({ ...f, address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('chat.phone')}>
|
||||
<input className="bo-input" value={f.phone} maxLength={40} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('vn.coords')} hint={f.coords.trim() && !coords ? t('crm.coordsBad') : t('crm.coordsPh')}>
|
||||
<input className="bo-input" value={f.coords} onChange={(e) => setF({ ...f, coords: e.target.value })} placeholder="40.1811, 44.5136" />
|
||||
</Field>
|
||||
<Field label={t('ag.sales')}>
|
||||
<select className="bo-select" value={f.salesId} onChange={(e) => setF({ ...f, salesId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{team.data?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('crm.note')}>
|
||||
<textarea className="bo-textarea" style={{ minHeight: 60 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
|
||||
</Field>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('vn.activeLabel')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Текст на 5 языках: русский обязателен, остальные — по желанию (клиент увидит русский, если перевода нет) */
|
||||
function L5Fields({ label, hint, value, onChange }: { label: string; hint?: string; value: L5; onChange: (v: L5) => void }) {
|
||||
const [open, setOpen] = useState(!!(value.en || value.hy || value.zh || value.es));
|
||||
return (
|
||||
<div className="bo-field">
|
||||
<span className="bo-label">{label} · RU</span>
|
||||
<input className="bo-input" value={value.ru} maxLength={300} onChange={(e) => onChange({ ...value, ru: e.target.value })} />
|
||||
{hint && <span className="bo-hint">{hint}</span>}
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setOpen((o) => !o)}>
|
||||
EN · HY · ZH · ES {open ? '▴' : '▾'}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bo-row2">
|
||||
{(['en', 'hy', 'zh', 'es'] as const).map((l) => (
|
||||
<input key={l} className="bo-input" value={value[l]} maxLength={300} placeholder={l.toUpperCase()} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfferDialog({ partnerId, offer, onClose, onDone }: { partnerId: string; offer?: { id: string; title: Record<string, string>; minLevel: 'BAREV_PLUS' | 'ELITE' | null; limit: 'NONE' | 'ONCE' | 'WEEK' | 'MONTH'; limitCount: number; active: boolean }; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const toast = useToast();
|
||||
const [f, setF] = useState({
|
||||
title: { ...EMPTY, ...(offer?.title ?? {}) } as L5,
|
||||
minLevel: offer?.minLevel ?? null,
|
||||
limit: offer?.limit ?? 'NONE',
|
||||
limitCount: String(offer?.limitCount ?? 1),
|
||||
active: offer?.active ?? true,
|
||||
});
|
||||
const save = useMutation(
|
||||
trpc.bo.partners.saveOffer.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={offer ? t('vn.editOffer') : t('vn.addOffer')}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
||||
{t('ui.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn"
|
||||
disabled={!f.title.ru.trim() || save.isPending}
|
||||
onClick={() => save.mutate({ id: offer?.id, partnerId, title: f.title, minLevel: f.minLevel, limit: f.limit, limitCount: Math.max(1, Number(f.limitCount) || 1), active: f.active })}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<L5Fields label={t('vn.offerTitle')} hint={t('vn.offerTitleHint')} value={f.title} onChange={(title) => setF({ ...f, title })} />
|
||||
<Field label={t('vn.forWhom')}>
|
||||
<div className="bo-pills">
|
||||
{([null, 'BAREV_PLUS', 'ELITE'] as const).map((l) => (
|
||||
<button key={l ?? 'any'} type="button" className="bo-pill" aria-pressed={f.minLevel === l} onClick={() => setF({ ...f, minLevel: l })}>
|
||||
{l ? t(`vn.level.${l}`) : t('vn.level.any')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('vn.howOften')}>
|
||||
<select className="bo-select" value={f.limit} onChange={(e) => setF({ ...f, limit: e.target.value as typeof f.limit })}>
|
||||
{(['NONE', 'ONCE', 'WEEK', 'MONTH'] as const).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{t(`vn.limitOpt.${l}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{(f.limit === 'WEEK' || f.limit === 'MONTH') && (
|
||||
<Field label={t('vn.times')}>
|
||||
<input className="bo-input" inputMode="numeric" value={f.limitCount} onChange={(e) => setF({ ...f, limitCount: e.target.value.replace(/\D/g, '') })} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
|
||||
{t('vn.offerActive')}
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user